@vimhead.dev/norn-cli 0.1.0-tip.35359392805.1 → 0.1.0-tip.35436871363.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/assets/README.md +22 -27
  2. package/assets/docs/README.md +5 -5
  3. package/assets/docs/agents.md +48 -9
  4. package/assets/docs/cli.md +7 -7
  5. package/assets/docs/composition.md +25 -25
  6. package/assets/docs/persistence.md +34 -23
  7. package/assets/docs/projects.md +30 -18
  8. package/assets/docs/providers.md +3 -3
  9. package/assets/docs/recovery.md +13 -9
  10. package/assets/docs/schemas.md +1 -1
  11. package/assets/docs/workflows.md +105 -12
  12. package/assets/examples/agent-then-analysis/README.md +10 -10
  13. package/assets/examples/agent-then-analysis/input.json +1 -1
  14. package/assets/examples/agent-then-analysis/norn.project.json +1 -1
  15. package/assets/examples/agent-then-analysis/plugin.ts +57 -68
  16. package/assets/examples/caller-selected-continuation/README.md +15 -14
  17. package/assets/examples/caller-selected-continuation/caller.ts +37 -38
  18. package/assets/examples/caller-selected-continuation/input.json +2 -2
  19. package/assets/examples/caller-selected-continuation/norn.project.json +1 -1
  20. package/assets/examples/caller-selected-continuation/producer.ts +19 -24
  21. package/assets/examples/coordinating-multiple-agents/README.md +27 -17
  22. package/assets/examples/coordinating-multiple-agents/input.json +1 -1
  23. package/assets/examples/coordinating-multiple-agents/norn.project.json +1 -1
  24. package/assets/examples/coordinating-multiple-agents/plugin.ts +79 -60
  25. package/assets/examples/coordinating-multiple-agents/queue-tools.ts +43 -0
  26. package/assets/examples/coordinating-multiple-agents/work-queue.ts +60 -65
  27. package/assets/examples/getting-started/README.md +2 -2
  28. package/assets/examples/getting-started/norn.project.json +1 -1
  29. package/assets/examples/getting-started/plugin.ts +20 -26
  30. package/assets/examples/minimal-workflow/README.md +13 -12
  31. package/assets/examples/minimal-workflow/norn.project.json +1 -1
  32. package/assets/examples/minimal-workflow/plugin.ts +14 -25
  33. package/assets/examples/shared-state/README.md +12 -4
  34. package/assets/examples/shared-state/input.json +1 -1
  35. package/assets/examples/shared-state/norn.project.json +1 -1
  36. package/assets/examples/shared-state/plugin.ts +49 -41
  37. package/assets/examples/shared-state/shared-state.ts +56 -0
  38. package/assets/examples/shared-state/state-tools.ts +68 -0
  39. package/assets/examples/worktree-development-loop/README.md +18 -17
  40. package/assets/examples/worktree-development-loop/norn.project.json +1 -1
  41. package/assets/examples/worktree-development-loop/plugin.ts +6 -26
  42. package/assets/examples/worktree-development-loop/scope.ts +4 -0
  43. package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +14 -15
  44. package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +3 -4
  45. package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +5 -3
  46. package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +2 -2
  47. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +35 -33
  48. package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +2 -3
  49. package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +6 -3
  50. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +27 -16
  51. package/assets/examples/worktree-development-loop/workflows/planning/index.ts +2 -3
  52. package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +4 -2
  53. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +43 -31
  54. package/assets/examples/worktree-development-loop/workflows/review/index.ts +3 -4
  55. package/assets/examples/worktree-development-loop/workflows/review/schema.ts +6 -6
  56. package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +50 -44
  57. package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +2 -3
  58. package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +5 -5
  59. package/assets/package.json +1 -1
  60. package/assets/packages/cli/src/cli.ts +44 -37
  61. package/assets/packages/cli/src/client.ts +9 -16
  62. package/assets/packages/cli/src/documentation-intro.ts +1 -1
  63. package/assets/packages/cli/src/generated-build-info.ts +2 -2
  64. package/assets/packages/cli/src/internal/agent-response-tool.ts +3 -3
  65. package/assets/packages/cli/src/internal/agents.ts +24 -32
  66. package/assets/packages/cli/src/internal/commands.ts +2 -14
  67. package/assets/packages/cli/src/internal/engine.ts +63 -101
  68. package/assets/packages/cli/src/internal/errors.ts +4 -4
  69. package/assets/packages/cli/src/internal/launch-request.ts +6 -6
  70. package/assets/packages/cli/src/internal/logs.ts +1 -1
  71. package/assets/packages/cli/src/internal/run-log.ts +1 -1
  72. package/assets/packages/cli/src/internal/run-state.ts +38 -23
  73. package/assets/packages/cli/src/internal/run.ts +4 -62
  74. package/assets/packages/cli/src/internal/worker-directory.ts +17 -0
  75. package/assets/packages/cli/src/internal/workflow-registry.ts +126 -111
  76. package/assets/packages/cli/src/internal/working-directory.ts +6 -0
  77. package/assets/packages/cli/src/workflow-loader.ts +213 -0
  78. package/assets/packages/core/src/workflow-transition.ts +2 -2
  79. package/assets/packages/sdk/src/api.ts +141 -329
  80. package/assets/packages/sdk/src/index.ts +1 -4
  81. package/assets/packages/sdk/src/schema.ts +3 -3
  82. package/assets/tests/workflow-ref.test.ts +53 -55
  83. package/dist/cli.js +41 -35
  84. package/dist/client.d.ts +3 -4
  85. package/dist/client.js +3 -8
  86. package/dist/documentation-intro.js +1 -1
  87. package/dist/generated-build-info.d.ts +2 -2
  88. package/dist/generated-build-info.js +2 -2
  89. package/dist/internal/agent-response-tool.js +3 -3
  90. package/dist/internal/agents.d.ts +0 -4
  91. package/dist/internal/agents.js +30 -46
  92. package/dist/internal/commands.d.ts +0 -4
  93. package/dist/internal/commands.js +2 -10
  94. package/dist/internal/engine.d.ts +4 -8
  95. package/dist/internal/engine.js +57 -84
  96. package/dist/internal/errors.d.ts +3 -3
  97. package/dist/internal/errors.js +1 -1
  98. package/dist/internal/file-coordinator.d.ts +17 -0
  99. package/dist/internal/file-coordinator.js +162 -0
  100. package/dist/internal/launch-request.d.ts +4 -4
  101. package/dist/internal/launch-request.js +2 -2
  102. package/dist/internal/logs.d.ts +1 -1
  103. package/dist/internal/run-log.d.ts +1 -1
  104. package/dist/internal/run-state.d.ts +11 -8
  105. package/dist/internal/run-state.js +34 -22
  106. package/dist/internal/run.d.ts +3 -23
  107. package/dist/internal/run.js +4 -50
  108. package/dist/internal/worker-directory.d.ts +1 -0
  109. package/dist/internal/worker-directory.js +26 -0
  110. package/dist/internal/workflow-registry.d.ts +35 -17
  111. package/dist/internal/workflow-registry.js +110 -73
  112. package/dist/internal/working-directory.d.ts +1 -0
  113. package/dist/internal/working-directory.js +9 -0
  114. package/dist/{plugin-loader.d.ts → workflow-loader.d.ts} +7 -8
  115. package/dist/workflow-loader.js +234 -0
  116. package/package.json +2 -2
  117. package/assets/docs/resources.md +0 -61
  118. package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +0 -51
  119. package/assets/examples/worktree-development-loop/manifest.ts +0 -26
  120. package/assets/examples/worktree-development-loop/state.ts +0 -23
  121. package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +0 -8
  122. package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +0 -8
  123. package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +0 -8
  124. package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +0 -8
  125. package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +0 -12
  126. package/assets/packages/cli/src/internal/artifacts.ts +0 -26
  127. package/assets/packages/cli/src/internal/resource-bindings.ts +0 -35
  128. package/assets/packages/cli/src/internal/run-resources.ts +0 -23
  129. package/assets/packages/cli/src/internal/state-store.ts +0 -83
  130. package/assets/packages/cli/src/plugin-loader.ts +0 -400
  131. package/assets/packages/cli/src/resources.ts +0 -69
  132. package/assets/packages/sdk/src/agent-resource-adapter.ts +0 -11
  133. package/assets/packages/sdk/src/resources.ts +0 -20
  134. package/assets/packages/sdk/src/state-adapter.ts +0 -75
  135. package/dist/internal/artifacts.d.ts +0 -10
  136. package/dist/internal/artifacts.js +0 -29
  137. package/dist/internal/resource-bindings.d.ts +0 -13
  138. package/dist/internal/resource-bindings.js +0 -34
  139. package/dist/internal/run-resources.d.ts +0 -6
  140. package/dist/internal/run-resources.js +0 -26
  141. package/dist/internal/state-store.d.ts +0 -23
  142. package/dist/internal/state-store.js +0 -103
  143. package/dist/plugin-loader.js +0 -341
  144. package/dist/resources.d.ts +0 -11
  145. package/dist/resources.js +0 -100
  146. /package/assets/packages/{sdk/src/files.ts → cli/src/internal/file-coordinator.ts} +0 -0
@@ -5,7 +5,7 @@ import { isNodeError } from "@vimhead.dev/norn-core/errors";
5
5
  import { readRunLaunchRequest, readOptionalRunResumeRequest, RESUME_START_GRACE_MS } from "./launch-request.ts";
6
6
  import { getRunLeaseHealth } from "./run-lease.ts";
7
7
  import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
8
- import { createRunFileCoordinator, type NornFileCoordinator } from "@vimhead.dev/norn/files";
8
+ import { createRunFileCoordinator, type NornFileCoordinator } from "./file-coordinator.ts";
9
9
  import { runCurrentRoot } from "./run-store.ts";
10
10
  import { jsonValueSchema } from "@vimhead.dev/norn/schema";
11
11
  import { Value } from "typebox/value";
@@ -13,19 +13,19 @@ import { Value } from "typebox/value";
13
13
  export const RUN_STATE_FILE_NAME = "run-state.json";
14
14
  const LEGACY_RUN_STATE_FILE_NAME = "runtime-state.json";
15
15
 
16
- export function mergeInterruptedWorkflowParams(currentParams: unknown, params: unknown, fields: readonly string[] | undefined): unknown {
17
- if (!isRecord(currentParams) || !isRecord(params)) return params;
16
+ export function mergeInterruptedWorkflowArgs(currentArgs: unknown, args: unknown, fields: readonly string[] | undefined): unknown {
17
+ if (!isRecord(currentArgs) || !isRecord(args)) return args;
18
18
  if (fields) {
19
- const unsupportedFields = Object.keys(params).filter((field) => !fields.includes(field));
20
- if (unsupportedFields.length > 0) throw new Error(`Interrupted workflow params cannot update non-gate fields: ${unsupportedFields.join(", ")}`);
19
+ const unsupportedFields = Object.keys(args).filter((field) => !fields.includes(field));
20
+ if (unsupportedFields.length > 0) throw new Error(`Interrupted workflow args cannot update non-gate fields: ${unsupportedFields.join(", ")}`);
21
21
  }
22
- return { ...currentParams, ...params };
22
+ return { ...currentArgs, ...args };
23
23
  }
24
- const RUN_STATE_VERSION = 1;
24
+ const RUN_STATE_VERSION = 2;
25
25
 
26
26
  type NornRunWorkflowStep = {
27
27
  readonly workflowId: string;
28
- readonly params: unknown;
28
+ readonly args: unknown;
29
29
  readonly cwd: string;
30
30
  readonly env: Record<string, string>;
31
31
  readonly interruption?: RuntimeInterruption;
@@ -58,7 +58,8 @@ type NornRunWorkflowOutcome = {
58
58
  };
59
59
 
60
60
  export type NornRunState = {
61
- readonly version: typeof RUN_STATE_VERSION;
61
+ readonly version: 1 | typeof RUN_STATE_VERSION;
62
+ readonly projectRoot?: string;
62
63
  readonly id: string;
63
64
  readonly name: string;
64
65
  readonly entrypointWorkflowId: string;
@@ -74,6 +75,7 @@ export type NornRunState = {
74
75
  };
75
76
 
76
77
  type CreateNornRunStateInput = {
78
+ readonly projectRoot: string;
77
79
  readonly id: string;
78
80
  readonly name: string;
79
81
  readonly entrypointWorkflowId: string;
@@ -101,7 +103,8 @@ export class NornRunStateStore {
101
103
  id: input.id,
102
104
  name: input.name,
103
105
  entrypointWorkflowId: input.entrypointWorkflowId,
104
- workspace: input.workspace,
106
+ projectRoot: resolve(input.projectRoot),
107
+ workspace: resolve(input.workspace),
105
108
  configOverride: input.configOverride,
106
109
  status: "running",
107
110
  current: input.current,
@@ -119,7 +122,7 @@ export class NornRunStateStore {
119
122
  static async load(runRoot: string): Promise<NornRunStateStore> {
120
123
  const currentRoot = runCurrentRoot(runRoot);
121
124
  const files = createRunFileCoordinator(runRoot);
122
- const state = await files.withExclusiveLock(join(currentRoot, RUN_STATE_FILE_NAME), async (path) => parseNornRunState(await readRunStateFile({ path, legacyPath: join(currentRoot, LEGACY_RUN_STATE_FILE_NAME) })));
125
+ const state = await files.withExclusiveLock(join(currentRoot, RUN_STATE_FILE_NAME), async (path) => parseNornRunState(await readRunStateFile({ path, legacyPath: join(currentRoot, LEGACY_RUN_STATE_FILE_NAME) }), "execute"));
123
126
  return new NornRunStateStore({ path: join(currentRoot, RUN_STATE_FILE_NAME), state, files });
124
127
  }
125
128
 
@@ -163,11 +166,11 @@ export class NornRunStateStore {
163
166
  });
164
167
  }
165
168
 
166
- async interruptCurrent(params: unknown, interruption: { readonly description: string; readonly fields?: readonly string[] }): Promise<void> {
169
+ async interruptCurrent(args: unknown, interruption: { readonly description: string; readonly fields?: readonly string[] }): Promise<void> {
167
170
  if (!this.state.current) throw new Error("Cannot interrupt run without current step");
168
171
  await this.update({
169
172
  status: "interrupted",
170
- current: { ...this.state.current, params, interruption: { status: "pending", ...interruption } },
173
+ current: { ...this.state.current, args, interruption: { status: "pending", ...interruption } },
171
174
  outcome: null,
172
175
  failed: null,
173
176
  });
@@ -185,11 +188,11 @@ export class NornRunStateStore {
185
188
  await this.update({ status: "stopped", outcome: null, failed: null });
186
189
  }
187
190
 
188
- async replaceCurrentParams(params: unknown): Promise<void> {
191
+ async replaceCurrentArgs(args: unknown): Promise<void> {
189
192
  if (!this.state.current) throw new Error("Cannot resume run without current step");
190
193
  await this.update({
191
194
  status: "running",
192
- current: { ...this.state.current, params, interruption: { ...this.state.current.interruption, status: "satisfied" } },
195
+ current: { ...this.state.current, args, interruption: { ...this.state.current.interruption, status: "satisfied" } },
193
196
  outcome: null,
194
197
  failed: null,
195
198
  });
@@ -208,16 +211,16 @@ export class NornRunStateStore {
208
211
 
209
212
  private async update(patch: Partial<NornRunState>): Promise<void> {
210
213
  await this.files.withExclusiveLock(this.path, async (path) => {
211
- const current = parseNornRunState(await readRunStateFile({ path, legacyPath: join(dirname(this.path), LEGACY_RUN_STATE_FILE_NAME) }));
214
+ const current = parseNornRunState(await readRunStateFile({ path, legacyPath: join(dirname(this.path), LEGACY_RUN_STATE_FILE_NAME) }), "execute");
212
215
  const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
213
- if (next.current?.params !== undefined) Value.Assert(jsonValueSchema, next.current.params);
216
+ if (next.current?.args !== undefined) Value.Assert(jsonValueSchema, next.current.args);
214
217
  await writeJsonAtomically(path, next);
215
218
  this.state = next;
216
219
  });
217
220
  }
218
221
 
219
222
  private async write(): Promise<void> {
220
- if (this.state.current?.params !== undefined) Value.Assert(jsonValueSchema, this.state.current.params);
223
+ if (this.state.current?.args !== undefined) Value.Assert(jsonValueSchema, this.state.current.args);
221
224
  await mkdir(dirname(this.path), { recursive: true });
222
225
  await this.files.withExclusiveLock(this.path, (path) => writeJsonAtomically(path, this.state));
223
226
  }
@@ -239,13 +242,20 @@ export async function listRuns(sessionCwd: string): Promise<NornRunInfo[]> {
239
242
 
240
243
  export async function getRunInfo(runRoot: string): Promise<NornRunInfo> {
241
244
  try {
242
- const resumeRequest = await readOptionalRunResumeRequest(runRoot);
243
- const state = parseNornRunState(await readRunStateJson(runCurrentRoot(runRoot)));
245
+ // Read the request first so a completed handoff cannot revive an old interruption.
246
+ const pendingResume = await readOptionalRunResumeRequest(runRoot).then(
247
+ request => ({ status: "fulfilled" as const, request }),
248
+ (error: unknown) => ({ status: "rejected" as const, error }),
249
+ );
250
+ const state = parseNornRunState(await readRunStateJson(runCurrentRoot(runRoot)), "inspect");
251
+ if (state.version === RUN_STATE_VERSION && pendingResume.status === "rejected") throw pendingResume.error;
252
+ const resumeRequest = state.version === RUN_STATE_VERSION && pendingResume.status === "fulfilled" ? pendingResume.request : undefined;
244
253
  return {
245
254
  version: state.version,
246
255
  id: state.id,
247
256
  name: state.name,
248
257
  path: runRoot,
258
+ paths: { project: state.projectRoot ?? resolve(runRoot, "../../.."), workspace: state.workspace },
249
259
  entrypointWorkflowId: state.entrypointWorkflowId,
250
260
  currentWorkflowId: state.current?.workflowId,
251
261
  status: resumeRequest ? "running" : state.status,
@@ -311,6 +321,7 @@ async function getLaunchedRunInfo(runRoot: string): Promise<NornRunInfo> {
311
321
  id: launchRequest.id,
312
322
  name: launchRequest.name,
313
323
  path: runRoot,
324
+ paths: { project: resolve(runRoot, "../../.."), workspace: join(runCurrentRoot(resolve(runRoot)), "workspace") },
314
325
  entrypointWorkflowId: launchRequest.workflowId,
315
326
  currentWorkflowId: launchRequest.workflowId,
316
327
  status: "running",
@@ -337,10 +348,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
337
348
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
338
349
  }
339
350
 
340
- function parseNornRunState(value: unknown): NornRunState {
351
+ export function assertRunVersion(version: unknown): void {
352
+ if (version !== RUN_STATE_VERSION) throw new Error(`Incompatible saved run state version ${String(version)}; this alpha requires version ${RUN_STATE_VERSION}. Use the original Norn version to resume this run, or start a new run. Saved files have not been migrated.`);
353
+ }
354
+
355
+ function parseNornRunState(value: unknown, mode: "execute" | "inspect"): NornRunState {
341
356
  if (!value || typeof value !== "object") throw new Error("Invalid run state");
342
357
  const state = value as Partial<NornRunState> & { rootWorkflowId?: unknown };
343
- if (state.version !== RUN_STATE_VERSION) throw new Error(`Unsupported run state version: ${String(state.version)}`);
358
+ if (mode === "execute" || state.version !== 1) assertRunVersion(state.version);
344
359
  if (typeof state.id !== "string" || state.id.length === 0) throw new Error("Invalid run state id");
345
360
  const normalizedState = state as { name?: string; entrypointWorkflowId?: unknown; rootWorkflowId?: unknown };
346
361
  if (typeof normalizedState.name !== "string" || normalizedState.name.length === 0) normalizedState.name = state.id;
@@ -369,7 +384,7 @@ function runInterruption(state: NornRunState): NornRunInterruption | undefined {
369
384
  if (state.status !== "interrupted" || !state.current?.interruption) return undefined;
370
385
  return {
371
386
  workflowId: state.current.workflowId,
372
- params: state.current.params,
387
+ args: state.version === 1 ? (state.current as { readonly params?: unknown }).params : state.current.args,
373
388
  description: state.current.interruption.description ?? "",
374
389
  fields: state.current.interruption.fields,
375
390
  };
@@ -1,63 +1,38 @@
1
- import { isAbsolute, relative, resolve, sep } from "node:path";
2
1
  import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
3
- import type { NornAnyWorkflowDeclaration, NornProjectRun, NornRunOutcomeMetadata, NornWorkflowIsolationMode, NornRun } from "@vimhead.dev/norn";
2
+ import type { NornRunOutcomeMetadata, NornRun } from "@vimhead.dev/norn";
4
3
  import { createWorkflowTransition } from "@vimhead.dev/norn-core/workflow-transition";
5
4
  import type { NornAgentResponseCollector } from "./agent-response-tool.ts";
6
- import type { NornArtifacts } from "./artifacts.ts";
7
5
  import { NornAgentRunner } from "./agents.ts";
8
6
  import { NornCommandRunner } from "./commands.ts";
9
7
  import type { NornRunLogs } from "./logs.ts";
10
8
  import type { NornRunLogger } from "./run-log.ts";
11
- import type { NornRunResources } from "../resources.ts";
12
9
 
13
10
  type DefaultNornRunInput = {
14
11
  readonly id: string;
15
12
  readonly runRoot: string;
16
- readonly projectRoot: string;
17
- readonly workspace: string;
18
- readonly cwd: string;
19
- readonly isolationMode: NornWorkflowIsolationMode;
20
13
  readonly signal?: AbortSignal;
21
14
  readonly model?: CreateAgentSessionOptions["model"];
22
15
  readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
23
16
  readonly agentDir?: string;
24
17
  readonly responseCollector: NornAgentResponseCollector;
25
- readonly resources: NornRunResources;
26
- readonly state: NornRun["state"];
27
18
  readonly logger: NornRunLogger;
28
- readonly artifacts: NornArtifacts;
29
19
  readonly logs: NornRunLogs;
30
20
  };
31
21
 
32
- export class NornRunContext implements NornProjectRun {
33
- readonly resources: NornRunResources;
34
- readonly state: NornRun["state"];
35
- readonly artifacts: NornRun["artifacts"];
22
+ export class NornRunContext implements NornRun {
36
23
  readonly logs: NornRun["logs"];
37
24
  readonly commands: NornRun["commands"];
38
25
  readonly agents: NornRun["agents"];
39
26
  readonly id: string;
40
- readonly workspace: string;
41
- readonly projectRoot: string;
42
- readonly cwd: string;
43
27
 
44
28
  constructor(private readonly input: DefaultNornRunInput) {
45
29
  this.id = input.id;
46
- this.workspace = input.workspace;
47
- this.projectRoot = input.projectRoot;
48
- this.cwd = input.cwd;
49
- this.resources = input.resources;
50
- this.state = input.state;
51
- this.artifacts = input.artifacts;
52
30
  this.logs = {
53
31
  read: (log) => this.input.logs.read(log),
54
32
  };
55
33
  this.commands = {
56
34
  run: (commandInput) =>
57
35
  new NornCommandRunner({
58
- boundaryRoot: this.boundaryRoot,
59
- boundaryName: this.input.isolationMode,
60
- cwd: this.cwd,
61
36
  signal: this.input.signal,
62
37
  logs: this.input.logs,
63
38
  logger: this.input.logger,
@@ -69,26 +44,10 @@ export class NornRunContext implements NornProjectRun {
69
44
  };
70
45
  }
71
46
 
72
- forWorkflow(workflow: NornAnyWorkflowDeclaration): NornRunContext {
73
- const isolationMode = workflow.isolation.mode;
74
- return new NornRunContext({
75
- ...this.input,
76
- isolationMode,
77
- cwd: isolationMode === "project" ? this.projectRoot : this.workspace,
78
- });
79
- }
80
-
81
- private get boundaryRoot(): string {
82
- return this.input.isolationMode === "project" ? this.projectRoot : this.workspace;
83
- }
84
-
85
47
  private createAgentRunner(): NornAgentRunner {
86
48
  return new NornAgentRunner({
87
49
  id: this.id,
88
50
  runRoot: this.input.runRoot,
89
- boundaryRoot: this.boundaryRoot,
90
- boundaryName: this.input.isolationMode,
91
- cwd: this.cwd,
92
51
  signal: this.input.signal,
93
52
  model: this.input.model,
94
53
  thinkingLevel: this.input.thinkingLevel,
@@ -99,16 +58,8 @@ export class NornRunContext implements NornProjectRun {
99
58
  });
100
59
  }
101
60
 
102
- path(relativePath: string): string {
103
- return this.resolveFromRoot(this.cwd, this.boundaryRoot, relativePath);
104
- }
105
-
106
- projectPath(relativePath: string): string {
107
- return this.resolveFromRoot(this.projectRoot, this.projectRoot, relativePath);
108
- }
109
-
110
- next(workflowId: string, params: unknown): ReturnType<NornRun["next"]> {
111
- return createWorkflowTransition({ workflowId, params });
61
+ next(workflowId: string, args: unknown): ReturnType<NornRun["next"]> {
62
+ return createWorkflowTransition({ workflowId, args });
112
63
  }
113
64
 
114
65
  complete(metadata?: NornRunOutcomeMetadata): ReturnType<NornRun["complete"]> {
@@ -118,13 +69,4 @@ export class NornRunContext implements NornProjectRun {
118
69
  fail(metadata: NornRunOutcomeMetadata & { readonly summary: string }): ReturnType<NornRun["fail"]> {
119
70
  return { type: "fail", metadata };
120
71
  }
121
-
122
- private resolveFromRoot(root: string, boundaryRoot: string, path: string): string {
123
- const resolvedPath = isAbsolute(path) ? path : resolve(root, path);
124
- const pathFromBoundary = relative(boundaryRoot, resolvedPath);
125
- if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
126
- throw new Error(`Workflow path escapes ${this.input.isolationMode} isolation: ${path}`);
127
- }
128
- return resolvedPath;
129
- }
130
72
  }
@@ -0,0 +1,17 @@
1
+ import { chmod, lstat, mkdir, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
4
+
5
+ export async function prepareRunWorkerDirectory(runRoot: string): Promise<string> {
6
+ const directory = join(runRoot, "worker");
7
+ try {
8
+ await mkdir(directory, { mode: 0o555 });
9
+ } catch (error) {
10
+ if (!isNodeError(error) || error.code !== "EEXIST") throw error;
11
+ }
12
+ const metadata = await lstat(directory);
13
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`Run worker cwd must be a directory, not a symlink: ${directory}`);
14
+ if ((await readdir(directory)).length !== 0) throw new Error(`Run worker cwd must be empty: ${directory}`);
15
+ await chmod(directory, 0o555);
16
+ return directory;
17
+ }
@@ -1,24 +1,40 @@
1
- import { isWorkflowComplete, isWorkflowFail, isWorkflowNext, type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type NornRunFor, type NornRunNext, type NornWorkflowGateInfo, type NornWorkflowImplementation, type NornWorkflowParams, type NornWorkflowPluginInfo } from "@vimhead.dev/norn";
1
+ import { isWorkflowComplete, isWorkflowDeclaration, isWorkflowFail, isWorkflowNext, type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type NornRun, type NornRunNext, type NornWorkflowPaths, type NornWorkflowContext, type NornWorkflowGateInfo, type NornWorkflowSource, type NornWorkflowScopeInfo, type NornProjectConfigurationInfo } from "@vimhead.dev/norn";
2
2
  import { assertWorkflowMetadata, inspectSchema, isPlainObject, schemaShape, schemaType, unwrapSchema } from "@vimhead.dev/norn/schema";
3
- import { type TSchema } from "typebox";
3
+ import type { TSchema } from "typebox";
4
4
  import { Value } from "typebox/value";
5
5
 
6
+ export type NornRegisteredConfiguration = { readonly key: string; readonly schema: TSchema | undefined; readonly input: unknown; readonly value: unknown };
6
7
  export type NornRegisteredWorkflow = {
7
- workflow: NornAnyWorkflowDeclaration;
8
- implementation: NornWorkflowImplementation<NornAnyWorkflowDeclaration, unknown>;
9
- configuration?: NornRegisteredConfiguration;
10
- plugin?: NornWorkflowPluginInfo;
8
+ readonly workflow: NornAnyWorkflowDeclaration;
9
+ readonly configuration: NornRegisteredConfiguration;
10
+ readonly scopeId: string | undefined;
11
+ readonly source: NornWorkflowSource | undefined;
11
12
  };
12
13
 
13
- export type NornRegisteredConfiguration = { readonly schema: TSchema; readonly input: unknown; readonly value: unknown };
14
+ type NornRegisteredScope = { readonly definition: NornWorkflowScopeInfo; readonly configuration: NornRegisteredConfiguration };
14
15
 
15
- export function decodePluginConfiguration(input: { readonly pluginId: string; readonly schema: TSchema | undefined; readonly value: unknown }): NornRegisteredConfiguration | undefined {
16
+ export function decodeConfiguration(input: { readonly key: string; readonly schema: TSchema | undefined; readonly value: unknown }): NornRegisteredConfiguration {
16
17
  if (!input.schema) {
17
- if (input.value !== undefined) throw new Error(`Norn config provided for plugin without config schema: ${input.pluginId}`);
18
- return undefined;
18
+ if (input.value !== undefined) throw new Error(`Norn config provided without config schema: ${input.key}`);
19
+ return { key: input.key, schema: undefined, input: undefined, value: undefined };
20
+ }
21
+ const value = input.value === undefined && schemaType(unwrapSchema(input.schema)) === "object" ? {} : input.value;
22
+ return { key: input.key, schema: input.schema, input: value, value: Value.Decode(input.schema, value) };
23
+ }
24
+
25
+ export function assertCompatibleConfigurationOwners(workflows: readonly NornAnyWorkflowDeclaration[]): void {
26
+ const owners = new Map<string, { readonly kind: "workflow" | "scope"; readonly definition: object }>();
27
+ for (const workflow of workflows) {
28
+ for (const owner of [
29
+ { key: workflow.id, kind: "workflow" as const, definition: workflow },
30
+ ...(workflow.scope ? [{ key: workflow.scope.id, kind: "scope" as const, definition: workflow.scope }] : []),
31
+ ]) {
32
+ const previous = owners.get(owner.key);
33
+ if (previous && previous.kind !== owner.kind) throw new Error(`Ambiguous configuration key: ${owner.key}`);
34
+ if (previous && owner.kind === "scope" && previous.definition !== owner.definition) throw new Error(`Duplicate workflow scope id: ${owner.key}; import one shared scope definition`);
35
+ owners.set(owner.key, owner);
36
+ }
19
37
  }
20
- const value = defaultConfigInput(input.schema, input.value);
21
- return { schema: input.schema, input: value, value: Value.Decode(input.schema, value) };
22
38
  }
23
39
 
24
40
  export type NornWorkflowStepResult =
@@ -26,157 +42,156 @@ export type NornWorkflowStepResult =
26
42
  | { readonly type: "complete"; readonly workflow: NornAnyWorkflowDeclaration; readonly metadata?: NornRunComplete["metadata"] }
27
43
  | { readonly type: "fail"; readonly workflow: NornAnyWorkflowDeclaration; readonly metadata: NornRunFail["metadata"] };
28
44
 
45
+ type NornExecutionContextInput = {
46
+ readonly run: NornRun;
47
+ readonly paths: NornWorkflowPaths;
48
+ readonly args: unknown;
49
+ readonly configOverride: unknown;
50
+ };
51
+
52
+ type NornWorkflowExecutionInput = NornExecutionContextInput & { readonly workflow: NornAnyWorkflowDeclaration };
53
+
29
54
  export class NornWorkflowRegistry {
30
55
  private readonly entries = new Map<string, NornRegisteredWorkflow>();
56
+ private readonly scopes = new Map<string, NornRegisteredScope>();
31
57
 
32
- register<TWorkflow extends NornAnyWorkflowDeclaration>(
33
- workflow: TWorkflow,
34
- implementation: NornWorkflowImplementation<TWorkflow, unknown>,
35
- metadata: { readonly plugin?: NornWorkflowPluginInfo; readonly configuration?: NornRegisteredConfiguration } = {},
36
- ): NornDispose {
58
+ register(input: { readonly workflow: NornAnyWorkflowDeclaration; readonly config: Readonly<Record<string, unknown>>; readonly source: NornWorkflowSource | undefined }): NornDispose {
59
+ const { workflow, config, source } = input;
60
+ assertWorkflowDefinition(workflow);
37
61
  if (this.entries.has(workflow.id)) throw new Error(`Workflow already registered: ${workflow.id}`);
38
-
62
+ assertCompatibleConfigurationOwners([...this.sortedEntries().map(entry => entry.workflow), workflow]);
39
63
  const entry: NornRegisteredWorkflow = {
40
- workflow,
41
- implementation: implementation as NornWorkflowImplementation<NornAnyWorkflowDeclaration, unknown>,
42
- configuration: metadata.configuration,
43
- plugin: metadata.plugin,
64
+ workflow, source, scopeId: workflow.scope?.id,
65
+ configuration: decodeConfiguration({ key: workflow.id, schema: workflow.config, value: configurationInput(config, workflow.id) }),
44
66
  };
45
- assertWorkflowMetadata(workflow);
46
- assertGateWorkflow(workflow);
67
+ if (workflow.scope && !this.scopes.has(workflow.scope.id)) {
68
+ this.scopes.set(workflow.scope.id, {
69
+ definition: workflow.scope,
70
+ configuration: decodeConfiguration({ key: workflow.scope.id, schema: workflow.scope.config, value: configurationInput(config, workflow.scope.id) }),
71
+ });
72
+ }
47
73
  this.entries.set(workflow.id, entry);
48
-
49
74
  return () => {
50
- if (this.entries.get(workflow.id) === entry) this.entries.delete(workflow.id);
75
+ if (this.entries.get(workflow.id) !== entry) return;
76
+ this.entries.delete(workflow.id);
77
+ if (entry.scopeId && ![...this.entries.values()].some(other => other.scopeId === entry.scopeId)) this.scopes.delete(entry.scopeId);
51
78
  };
52
79
  }
53
80
 
54
81
  list(options: { readonly entrypointsOnly?: boolean } = {}): NornRegisteredWorkflowInfo[] {
55
- const entries = options.entrypointsOnly ? this.launchableEntries() : this.sortedEntries();
56
- return entries.map((entry) => workflowInfo(entry));
82
+ return (options.entrypointsOnly ? this.launchableEntries() : this.sortedEntries()).map(workflowInfo);
57
83
  }
58
84
 
59
85
  inspect(workflowId: string): NornInspectedWorkflowInfo | undefined {
60
86
  const entry = this.entries.get(workflowId);
61
- return entry ? inspectedWorkflowInfo(entry) : undefined;
87
+ const scope = entry?.scopeId ? this.scopes.get(entry.scopeId) : undefined;
88
+ return entry ? {
89
+ ...workflowInfo(entry),
90
+ argsSchema: inspectSchema(entry.workflow.args),
91
+ configSchema: entry.configuration.schema ? inspectSchema(entry.configuration.schema) : null,
92
+ scope: scope ? {
93
+ id: scope.definition.id,
94
+ configKey: scope.configuration.key,
95
+ configSchema: scope.configuration.schema ? inspectSchema(scope.configuration.schema) : null,
96
+ } : undefined,
97
+ gate: gateInfo(entry.workflow),
98
+ } : undefined;
62
99
  }
63
100
 
64
- launchableEntries(): NornRegisteredWorkflow[] {
65
- return this.sortedEntries().filter(({ workflow }) => workflow.isEntrypoint);
101
+ configurationInfos(): NornProjectConfigurationInfo[] {
102
+ return [
103
+ ...this.sortedEntries().map(entry => ({ configuration: entry.configuration, scopeId: null })),
104
+ ...[...this.scopes.values()].map(scope => ({ configuration: scope.configuration, scopeId: scope.definition.id })),
105
+ ].map(({ configuration, scopeId }) => ({ key: configuration.key, scopeId, configSchema: configuration.schema ? inspectSchema(configuration.schema) : null, config: configuration.value }));
66
106
  }
67
107
 
68
- workflowById(workflowId: string): NornAnyWorkflowDeclaration | undefined {
69
- return this.entries.get(workflowId)?.workflow;
70
- }
108
+ launchableEntries(): NornRegisteredWorkflow[] { return this.sortedEntries().filter(({ workflow }) => workflow.isEntrypoint); }
109
+ workflowById(workflowId: string): NornAnyWorkflowDeclaration | undefined { return this.entries.get(workflowId)?.workflow; }
71
110
 
72
- async describeGate<TWorkflow extends NornAnyWorkflowDeclaration>(
73
- workflow: TWorkflow,
74
- run: NornRunFor<TWorkflow>,
75
- params: unknown,
76
- configOverride?: unknown,
77
- ): Promise<string> {
78
- const entry = this.entries.get(workflow.id);
79
- if (!entry) throw new Error(`Unknown workflow: ${workflow.id}`);
111
+ async describeGate(input: NornWorkflowExecutionInput): Promise<string> {
112
+ const { workflow } = input;
113
+ const entry = this.requireEntry(workflow.id);
80
114
  if (!workflow.gate) throw new Error(`Workflow is not gated: ${workflow.id}`);
81
- const parsedParams = Value.Decode(workflow.params, params) as NornWorkflowParams<TWorkflow>;
82
- const parsedConfig = parseExecutionConfig(entry, configOverride);
83
- const description = await (entry.implementation as NornWorkflowImplementation<TWorkflow, unknown>).gate?.describe(run, parsedParams, parsedConfig);
84
- return validateGateDescription(description ?? workflow.id, workflow.id);
115
+ const context = this.createExecutionContext({ ...input, entry });
116
+ const description = await entry.workflow.gate?.describe?.(context) ?? workflow.id;
117
+ if (typeof description !== "string" || description.trim().length === 0) throw new Error(`Workflow gate description must not be empty: ${workflow.id}`);
118
+ return description.trim();
85
119
  }
86
120
 
87
- async execute<TWorkflow extends NornAnyWorkflowDeclaration>(
88
- workflow: TWorkflow,
89
- run: NornRunFor<TWorkflow>,
90
- params: unknown,
91
- configOverride?: unknown,
92
- ): Promise<NornWorkflowStepResult> {
93
- const entry = this.entries.get(workflow.id);
94
- if (!entry) throw new Error(`Unknown workflow: ${workflow.id}`);
95
-
96
- const parsedParams = Value.Decode(workflow.params, params) as NornWorkflowParams<TWorkflow>;
97
- const parsedConfig = parseExecutionConfig(entry, configOverride);
98
- const result = await (entry.implementation as NornWorkflowImplementation<TWorkflow, unknown>).execute(run, parsedParams, parsedConfig);
121
+ async execute(input: NornWorkflowExecutionInput): Promise<NornWorkflowStepResult> {
122
+ const { workflow } = input;
123
+ const entry = this.requireEntry(workflow.id);
124
+ const result = await entry.workflow.execute(this.createExecutionContext({ ...input, entry }));
99
125
  if (isWorkflowNext(result)) return result;
100
126
  if (isWorkflowComplete(result)) return { type: "complete", workflow, metadata: result.metadata };
101
127
  if (isWorkflowFail(result)) return { type: "fail", workflow, metadata: result.metadata };
102
128
  throw new Error(`Workflow returned invalid control result: ${workflow.id}`);
103
129
  }
104
130
 
105
- private sortedEntries(): NornRegisteredWorkflow[] {
106
- return Array.from(this.entries.values()).sort((left, right) => left.workflow.id.localeCompare(right.workflow.id));
131
+ private createExecutionContext(input: NornExecutionContextInput & { readonly entry: NornRegisteredWorkflow }): NornWorkflowContext {
132
+ const { entry, run, paths, configOverride } = input;
133
+ const scope = entry.scopeId ? this.scopes.get(entry.scopeId) : undefined;
134
+ return {
135
+ args: Value.Decode(entry.workflow.args, input.args),
136
+ config: parseExecutionConfig(entry.configuration, configOverride),
137
+ ...(scope ? { scope: { id: scope.definition.id, config: parseExecutionConfig(scope.configuration, configOverride) } } : {}),
138
+ paths,
139
+ run,
140
+ };
107
141
  }
108
- }
109
142
 
110
- function defaultConfigInput(configSchema: TSchema, config: unknown): unknown {
111
- if (config !== undefined) return config;
112
- return schemaType(unwrapSchema(configSchema)) === "object" ? {} : undefined;
143
+ private requireEntry(id: string): NornRegisteredWorkflow {
144
+ const entry = this.entries.get(id);
145
+ if (!entry) throw new Error(`Unknown workflow: ${id}`);
146
+ return entry;
147
+ }
148
+ private sortedEntries(): NornRegisteredWorkflow[] { return [...this.entries.values()].sort((left, right) => left.workflow.id.localeCompare(right.workflow.id)); }
113
149
  }
114
150
 
115
- function parseExecutionConfig(entry: NornRegisteredWorkflow, configOverride: unknown): unknown {
116
- const pluginConfigOverride = pluginConfigOverrideInput(entry, configOverride);
117
- if (!entry.configuration) {
118
- if (pluginConfigOverride !== undefined) throw new Error(`Run config override provided for plugin without config schema: ${entry.plugin?.id ?? entry.workflow.id}`);
119
- return undefined;
120
- }
121
- if (pluginConfigOverride === undefined) return entry.configuration.value;
122
- const rawConfig = isPlainObject(entry.configuration.input) && isPlainObject(pluginConfigOverride)
123
- ? deepMerge(entry.configuration.input, pluginConfigOverride)
124
- : pluginConfigOverride;
125
- return Value.Decode(entry.configuration.schema, rawConfig);
151
+ function configurationInput(config: Readonly<Record<string, unknown>>, key: string): unknown {
152
+ return Object.hasOwn(config, key) ? config[key] : undefined;
126
153
  }
127
154
 
128
- function pluginConfigOverrideInput(entry: NornRegisteredWorkflow, configOverride: unknown): unknown {
129
- if (configOverride === undefined) return undefined;
130
- if (!isPlainObject(configOverride)) throw new Error("Run config override must be an object keyed by plugin id");
131
- const pluginId = entry.plugin?.id;
132
- return pluginId ? configOverride[pluginId] : undefined;
155
+ function parseExecutionConfig(configuration: NornRegisteredConfiguration, overrides: unknown): unknown {
156
+ if (overrides !== undefined && !isPlainObject(overrides)) throw new Error("Run config override must be an object keyed by workflow or scope id");
157
+ const override = overrides ? configurationInput(overrides, configuration.key) : undefined;
158
+ if (override === undefined) return configuration.value;
159
+ if (!configuration.schema) throw new Error(`Run config override provided without config schema: ${configuration.key}`);
160
+ const input = isPlainObject(configuration.input) && isPlainObject(override) ? deepMerge(configuration.input, override) : override;
161
+ return Value.Decode(configuration.schema, input);
133
162
  }
134
163
 
135
164
  function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
136
- const result: Record<string, unknown> = { ...base };
137
- for (const [key, value] of Object.entries(override)) {
138
- const baseValue = result[key];
139
- result[key] = isPlainObject(baseValue) && isPlainObject(value) ? deepMerge(baseValue, value) : value;
140
- }
165
+ const result = { ...base };
166
+ for (const [key, value] of Object.entries(override)) result[key] = isPlainObject(result[key]) && isPlainObject(value) ? deepMerge(result[key], value) : value;
141
167
  return result;
142
168
  }
143
169
 
170
+ export function assertWorkflowDefinition(workflow: NornAnyWorkflowDeclaration): void {
171
+ assertWorkflowMetadata(workflow);
172
+ if (!isWorkflowDeclaration(workflow)) throw new Error("Invalid workflow definition");
173
+ assertGateWorkflow(workflow);
174
+ }
175
+
144
176
  function assertGateWorkflow(workflow: NornAnyWorkflowDeclaration): void {
145
177
  if (!workflow.gate) return;
146
178
  if (workflow.gate.enabled !== true) throw new Error(`Workflow gate must be enabled with true: ${workflow.id}`);
179
+ if (workflow.gate.describe !== undefined && typeof workflow.gate.describe !== "function") throw new Error(`Invalid gate description callback: ${workflow.id}`);
147
180
  if (!workflow.gate.fields) return;
148
- const paramsSchema = unwrapSchema(workflow.params);
149
- if (schemaType(paramsSchema) !== "object") throw new Error(`Workflow gate fields require object params: ${workflow.id}`);
150
- const paramsShape = schemaShape(paramsSchema);
151
- for (const field of workflow.gate.fields) {
152
- if (!Object.prototype.hasOwnProperty.call(paramsShape, field)) throw new Error(`Unknown workflow gate field ${field}: ${workflow.id}`);
153
- }
154
- }
155
-
156
- function validateGateDescription(description: string, workflowId: string): string {
157
- const trimmed = description.trim();
158
- if (trimmed.length === 0) throw new Error(`Workflow gate description must not be empty: ${workflowId}`);
159
- return trimmed;
160
- }
161
-
162
- function inspectedWorkflowInfo(entry: NornRegisteredWorkflow): NornInspectedWorkflowInfo {
163
- return {
164
- ...workflowInfo(entry),
165
- paramsSchema: inspectSchema(entry.workflow.params),
166
- gate: gateInfo(entry.workflow),
167
- };
181
+ const argsSchema = unwrapSchema(workflow.args);
182
+ if (schemaType(argsSchema) !== "object") throw new Error(`Workflow gate fields require object args: ${workflow.id}`);
183
+ const shape = schemaShape(argsSchema);
184
+ for (const field of workflow.gate.fields) if (!Object.hasOwn(shape, field)) throw new Error(`Unknown workflow gate field ${field}: ${workflow.id}`);
168
185
  }
169
186
 
170
187
  function workflowInfo(entry: NornRegisteredWorkflow): NornRegisteredWorkflowInfo {
171
188
  return {
172
- id: entry.workflow.id,
173
- instructions: entry.workflow.instructions,
174
- isEntrypoint: entry.workflow.isEntrypoint,
175
- isolation: entry.workflow.isolation,
176
- plugin: entry.plugin,
189
+ id: entry.workflow.id, instructions: entry.workflow.instructions, isEntrypoint: entry.workflow.isEntrypoint,
190
+ source: entry.source,
191
+ configKey: entry.workflow.id,
192
+ scope: entry.scopeId ? { id: entry.scopeId } : undefined,
177
193
  };
178
194
  }
179
-
180
195
  function gateInfo(workflow: NornAnyWorkflowDeclaration): NornWorkflowGateInfo | null {
181
196
  return workflow.gate ? { enabled: true, fields: workflow.gate.fields } : null;
182
197
  }
@@ -0,0 +1,6 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+
3
+ export function requireAbsoluteWorkingDirectory(cwd: string): string {
4
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) throw new Error("Working directory (cwd) must be an absolute path");
5
+ return resolve(cwd);
6
+ }