taskplane 0.1.14 → 0.1.16

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.
@@ -1,14 +1,14 @@
1
- /**
2
- * Config loading from YAML
3
- * @module orch/config
4
- */
5
- import { readFileSync, existsSync } from "fs";
6
- import { join } from "path";
7
- import { parse as yamlParse } from "yaml";
8
-
9
- import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG } from "./types.ts";
10
- import type { OrchestratorConfig, TaskArea, TaskRunnerConfig } from "./types.ts";
11
-
1
+ /**
2
+ * Config loading from YAML
3
+ * @module orch/config
4
+ */
5
+ import { readFileSync, existsSync } from "fs";
6
+ import { join } from "path";
7
+ import { parse as yamlParse } from "yaml";
8
+
9
+ import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG } from "./types.ts";
10
+ import type { OrchestratorConfig, TaskArea, TaskRunnerConfig } from "./types.ts";
11
+
12
12
  // ── Config Loading ───────────────────────────────────────────────────
13
13
 
14
14
  /**
@@ -84,11 +84,16 @@ export function loadTaskRunnerConfig(cwd: string): TaskRunnerConfig {
84
84
  if (loaded?.task_areas) {
85
85
  for (const [name, area] of Object.entries(loaded.task_areas)) {
86
86
  const a = area as any;
87
- taskAreas[name] = {
87
+ const ta: TaskArea = {
88
88
  path: a?.path || "",
89
89
  prefix: a?.prefix || "",
90
90
  context: a?.context || "",
91
91
  };
92
+ // Parse repo_id (snake_case YAML key) into repoId for routing
93
+ if (a?.repo_id && typeof a.repo_id === "string" && a.repo_id.trim()) {
94
+ ta.repoId = a.repo_id.trim();
95
+ }
96
+ taskAreas[name] = ta;
92
97
  }
93
98
  }
94
99
  return {
@@ -1,12 +1,13 @@
1
- /**
2
- * Task discovery, PROMPT.md parsing, dependency resolution
3
- * @module orch/discovery
4
- */
5
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
6
- import { join, dirname, basename, resolve } from "path";
7
-
8
- import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea } from "./types.ts";
9
-
1
+ /**
2
+ * Task discovery, PROMPT.md parsing, dependency resolution
3
+ * @module orch/discovery
4
+ */
5
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
6
+ import { join, dirname, basename, resolve } from "path";
7
+
8
+ import { FATAL_DISCOVERY_CODES } from "./types.ts";
9
+ import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea, WorkspaceConfig } from "./types.ts";
10
+
10
11
  // ── PROMPT.md Parsing ────────────────────────────────────────────────
11
12
 
12
13
  /**
@@ -184,6 +185,54 @@ export function parsePromptForOrchestrator(
184
185
  }
185
186
  }
186
187
 
188
+ // ── Extract execution target (repo ID) ──────────────────────
189
+ // Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum
190
+ const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
191
+
192
+ let promptRepoId: string | undefined;
193
+
194
+ // Priority 1: Section-based "## Execution Target" with "Repo: <id>" line
195
+ // Capture everything from section header to the next heading or --- divider.
196
+ // We avoid \n$ (which in multiline mode matches blank lines) by using a two-pass
197
+ // approach: find the section start, then slice to the next section boundary.
198
+ const execTargetHeaderIdx = content.search(/^##\s+Execution Target\s*$/m);
199
+ let execTargetSectionBody: string | null = null;
200
+ if (execTargetHeaderIdx !== -1) {
201
+ const afterHeader = content.indexOf("\n", execTargetHeaderIdx);
202
+ if (afterHeader !== -1) {
203
+ const rest = content.slice(afterHeader + 1);
204
+ const nextSectionMatch = rest.search(/^##\s|^---/m);
205
+ execTargetSectionBody = nextSectionMatch !== -1
206
+ ? rest.slice(0, nextSectionMatch)
207
+ : rest;
208
+ }
209
+ }
210
+ if (execTargetSectionBody !== null) {
211
+ // Match "Repo: api" or "**Repo:** api" or "Repo: api" with whitespace
212
+ const repoLineMatch = execTargetSectionBody.match(
213
+ /^\s*\*?\*?Repo:?\*?\*?\s+(\S+)/mi,
214
+ );
215
+ if (repoLineMatch) {
216
+ const candidate = repoLineMatch[1].trim().toLowerCase();
217
+ if (REPO_ID_PATTERN.test(candidate)) {
218
+ promptRepoId = candidate;
219
+ }
220
+ }
221
+ }
222
+
223
+ // Priority 2 (fallback): Inline "**Repo:** <id>" anywhere in content
224
+ if (!promptRepoId) {
225
+ const inlineRepoMatch = content.match(
226
+ /^\*\*Repo:\*\*\s+(\S+)/m,
227
+ );
228
+ if (inlineRepoMatch) {
229
+ const candidate = inlineRepoMatch[1].trim().toLowerCase();
230
+ if (REPO_ID_PATTERN.test(candidate)) {
231
+ promptRepoId = candidate;
232
+ }
233
+ }
234
+ }
235
+
187
236
  // ── Extract file scope ───────────────────────────────────────
188
237
  const fileScope: string[] = [];
189
238
  const fileScopeMatch = content.match(
@@ -214,12 +263,13 @@ export function parsePromptForOrchestrator(
214
263
  promptPath: resolve(promptPath),
215
264
  areaName,
216
265
  status: "pending",
266
+ ...(promptRepoId ? { promptRepoId } : {}),
217
267
  },
218
268
  error: null,
219
269
  };
220
270
  }
221
271
 
222
-
272
+
223
273
  // ── Area Scanning ────────────────────────────────────────────────────
224
274
 
225
275
  /**
@@ -291,7 +341,7 @@ export function scanAreaForTasks(
291
341
  return { tasks, errors };
292
342
  }
293
343
 
294
-
344
+
295
345
  // ── Completed Task Set ───────────────────────────────────────────────
296
346
 
297
347
  /**
@@ -361,7 +411,7 @@ export function buildCompletedTaskSet(areaPaths: string[]): Set<string> {
361
411
  return completed;
362
412
  }
363
413
 
364
-
414
+
365
415
  // ── Argument Resolution ──────────────────────────────────────────────
366
416
 
367
417
  /**
@@ -440,6 +490,8 @@ export interface DiscoveryOptions {
440
490
  refreshDependencies?: boolean;
441
491
  dependencySource?: "prompt" | "agent";
442
492
  useDependencyCache?: boolean;
493
+ /** Workspace config for repo routing (null/undefined = repo mode, no routing). */
494
+ workspaceConfig?: WorkspaceConfig | null;
443
495
  }
444
496
 
445
497
  export interface DependencyCacheFile {
@@ -531,7 +583,7 @@ export function applyDependenciesFromCache(
531
583
  return { applied };
532
584
  }
533
585
 
534
-
586
+
535
587
  // ── Task Registry ────────────────────────────────────────────────────
536
588
 
537
589
  /**
@@ -637,7 +689,7 @@ export function buildTaskRegistry(
637
689
  return { pending, completed, errors };
638
690
  }
639
691
 
640
-
692
+
641
693
  // ── Cross-Area Dependency Resolution ─────────────────────────────────
642
694
 
643
695
  /** Candidate match for a dependency reference found in task areas. */
@@ -806,7 +858,93 @@ export function resolveDependencies(
806
858
  return errors;
807
859
  }
808
860
 
809
-
861
+
862
+ // ── Task-to-Repo Routing ─────────────────────────────────────────────
863
+
864
+ /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */
865
+ const ROUTING_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
866
+
867
+ /**
868
+ * Resolve the target repo for each discovered task using the routing
869
+ * precedence chain:
870
+ *
871
+ * 1. `task.promptRepoId` — declared in PROMPT.md metadata
872
+ * 2. `taskArea.repoId` — area-level config from task-runner.yaml
873
+ * 3. `workspaceConfig.routing.defaultRepo` — workspace-level default
874
+ *
875
+ * Only applied in workspace mode (when `workspaceConfig` is provided).
876
+ * In repo mode this function is never called.
877
+ *
878
+ * Returns an array of DiscoveryError for routing failures:
879
+ * - TASK_REPO_UNRESOLVED: no source provided a repo ID
880
+ * - TASK_REPO_UNKNOWN: resolved repo ID is not in workspace repos map
881
+ */
882
+ export function resolveTaskRouting(
883
+ discovery: DiscoveryResult,
884
+ taskAreas: Record<string, TaskArea>,
885
+ workspaceConfig: WorkspaceConfig,
886
+ ): DiscoveryError[] {
887
+ const errors: DiscoveryError[] = [];
888
+ const validRepoIds = workspaceConfig.repos;
889
+
890
+ for (const task of discovery.pending.values()) {
891
+ // Precedence 1: prompt-declared repo
892
+ let resolvedId = task.promptRepoId;
893
+ let source = "prompt";
894
+
895
+ // Precedence 2: area-level repo
896
+ if (!resolvedId) {
897
+ const area = taskAreas[task.areaName];
898
+ if (area?.repoId) {
899
+ const candidate = area.repoId.trim().toLowerCase();
900
+ if (ROUTING_REPO_ID_PATTERN.test(candidate)) {
901
+ resolvedId = candidate;
902
+ source = "area";
903
+ }
904
+ }
905
+ }
906
+
907
+ // Precedence 3: workspace default repo
908
+ if (!resolvedId) {
909
+ resolvedId = workspaceConfig.routing.defaultRepo;
910
+ source = "default";
911
+ }
912
+
913
+ // Validate resolution
914
+ if (!resolvedId) {
915
+ errors.push({
916
+ code: "TASK_REPO_UNRESOLVED",
917
+ message:
918
+ `Task ${task.taskId} has no resolved repo. ` +
919
+ `Add a Repo: field to the PROMPT, set repo_id on area "${task.areaName}", ` +
920
+ `or set routing.default_repo in the workspace config.`,
921
+ taskId: task.taskId,
922
+ taskPath: task.promptPath,
923
+ });
924
+ continue;
925
+ }
926
+
927
+ if (!validRepoIds.has(resolvedId)) {
928
+ errors.push({
929
+ code: "TASK_REPO_UNKNOWN",
930
+ message:
931
+ `Task ${task.taskId} resolved to repo "${resolvedId}" (via ${source}), ` +
932
+ `but no repo with that ID exists in the workspace config. ` +
933
+ `Known repos: ${[...validRepoIds.keys()].join(", ")}`,
934
+ taskId: task.taskId,
935
+ taskPath: task.promptPath,
936
+ });
937
+ continue;
938
+ }
939
+
940
+ // Attach resolved repo to the task
941
+ task.resolvedRepoId = resolvedId;
942
+ }
943
+
944
+ return errors;
945
+ }
946
+
947
+
810
948
  // ── Discovery Pipeline (Public) ──────────────────────────────────────
811
949
 
812
950
  /**
@@ -901,6 +1039,13 @@ export function runDiscovery(
901
1039
  }
902
1040
  }
903
1041
 
1042
+ // Step 6: Task-to-repo routing (workspace mode only)
1043
+ const workspaceConfig = options.workspaceConfig;
1044
+ if (workspaceConfig && workspaceConfig.mode === "workspace") {
1045
+ const routingErrors = resolveTaskRouting(discovery, taskAreas, workspaceConfig);
1046
+ discovery.errors.push(...routingErrors);
1047
+ }
1048
+
904
1049
  return discovery;
905
1050
  }
906
1051
 
@@ -939,8 +1084,12 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
939
1084
  task.dependencies.length > 0
940
1085
  ? ` → depends on: ${task.dependencies.join(", ")}`
941
1086
  : "";
1087
+ const repo =
1088
+ task.resolvedRepoId
1089
+ ? ` → repo: ${task.resolvedRepoId}`
1090
+ : "";
942
1091
  lines.push(
943
- ` ${task.taskId} [${task.size}] ${task.taskName}${deps}`,
1092
+ ` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`,
944
1093
  );
945
1094
  }
946
1095
  }
@@ -949,22 +1098,9 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
949
1098
 
950
1099
  // Show errors
951
1100
  if (result.errors.length > 0) {
952
- const fatalErrors = result.errors.filter(
953
- (e) =>
954
- e.code === "DUPLICATE_ID" ||
955
- e.code === "DEP_UNRESOLVED" ||
956
- e.code === "DEP_PENDING" ||
957
- e.code === "DEP_AMBIGUOUS" ||
958
- e.code === "PARSE_MISSING_ID",
959
- );
960
- const warnings = result.errors.filter(
961
- (e) =>
962
- e.code !== "DUPLICATE_ID" &&
963
- e.code !== "DEP_UNRESOLVED" &&
964
- e.code !== "DEP_PENDING" &&
965
- e.code !== "DEP_AMBIGUOUS" &&
966
- e.code !== "PARSE_MISSING_ID",
967
- );
1101
+ const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
1102
+ const fatalErrors = result.errors.filter((e) => fatalCodes.has(e.code));
1103
+ const warnings = result.errors.filter((e) => !fatalCodes.has(e.code));
968
1104
 
969
1105
  if (fatalErrors.length > 0) {
970
1106
  lines.push("❌ Errors:");