taskplane 0.1.15 → 0.1.17

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/bin/taskplane.mjs CHANGED
@@ -292,15 +292,17 @@ async function autoCommitTaskFiles(projectRoot, tasksRoot) {
292
292
 
293
293
  function discoverTaskAreaMetadata(projectRoot) {
294
294
  const runnerPath = path.join(projectRoot, ".pi", "task-runner.yaml");
295
- if (!fs.existsSync(runnerPath)) return { paths: [], contexts: [] };
295
+ if (!fs.existsSync(runnerPath)) return { paths: [], contexts: [], areaRepoIds: {} };
296
296
 
297
297
  const raw = readYaml(runnerPath);
298
- if (!raw) return { paths: [], contexts: [] };
298
+ if (!raw) return { paths: [], contexts: [], areaRepoIds: {} };
299
299
 
300
300
  const lines = raw.split(/\r?\n/);
301
301
  let inTaskAreas = false;
302
+ let currentAreaName = null;
302
303
  const paths = new Set();
303
304
  const contexts = new Set();
305
+ const areaRepoIds = {}; // area name → repo_id (only areas that declare one)
304
306
 
305
307
  for (const line of lines) {
306
308
  const trimmed = line.trim();
@@ -317,6 +319,13 @@ function discoverTaskAreaMetadata(projectRoot) {
317
319
  break;
318
320
  }
319
321
 
322
+ // Area name line (2-space indent): " taskplane-tasks:"
323
+ const areaNameMatch = line.match(/^ ([A-Za-z0-9][A-Za-z0-9_-]*)\s*:\s*$/);
324
+ if (areaNameMatch) {
325
+ currentAreaName = areaNameMatch[1];
326
+ continue;
327
+ }
328
+
320
329
  const pathMatch = line.match(/^\s{4}path:\s*["']?([^"'\n#]+)["']?\s*(?:#.*)?$/);
321
330
  if (pathMatch?.[1]) {
322
331
  paths.add(pathMatch[1].trim());
@@ -326,9 +335,18 @@ function discoverTaskAreaMetadata(projectRoot) {
326
335
  if (contextMatch?.[1]) {
327
336
  contexts.add(contextMatch[1].trim());
328
337
  }
338
+
339
+ // Extract repo_id per area (workspace mode routing validation)
340
+ // Only store when trimmed value is non-empty — aligns with orchestrator
341
+ // config.ts behavior which ignores empty/whitespace repo_id values.
342
+ const repoIdMatch = line.match(/^\s{4}repo_id:\s*["']?([^"'\n#]+)["']?\s*(?:#.*)?$/);
343
+ const repoIdValue = repoIdMatch?.[1]?.trim();
344
+ if (repoIdValue && currentAreaName) {
345
+ areaRepoIds[currentAreaName] = repoIdValue;
346
+ }
329
347
  }
330
348
 
331
- return { paths: [...paths], contexts: [...contexts] };
349
+ return { paths: [...paths], contexts: [...contexts], areaRepoIds };
332
350
  }
333
351
 
334
352
  function discoverTaskAreaPaths(projectRoot) {
@@ -765,6 +783,215 @@ function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
765
783
  console.log();
766
784
  }
767
785
 
786
+ // ─── Workspace Mode Detection (for doctor) ─────────────────────────────────
787
+
788
+ /**
789
+ * Lightweight workspace config loader for doctor diagnostics.
790
+ *
791
+ * Unlike the orchestrator's `loadWorkspaceConfig()` in workspace.ts (which
792
+ * throws on invalid config), this returns a result object so doctor can
793
+ * report errors as diagnostics and continue checking remaining items.
794
+ *
795
+ * Mode determination rules (mirrors workspace.ts):
796
+ * 1. No config file → { mode: "repo", config: null, error: null }
797
+ * 2. Config file present + valid → { mode: "workspace", config: {...}, error: null }
798
+ * 3. Config file present + invalid → { mode: "workspace", config: null, error: { code, message } }
799
+ *
800
+ * @param {string} projectRoot - Absolute path to the project root
801
+ * @returns {Promise<{ mode: string, config: object|null, error: object|null }>}
802
+ */
803
+ function loadWorkspaceConfigForDoctor(projectRoot) {
804
+ const configFile = path.join(projectRoot, ".pi", "taskplane-workspace.yaml");
805
+
806
+ // 1. File existence — absent = repo mode
807
+ if (!fs.existsSync(configFile)) {
808
+ return { mode: "repo", config: null, error: null };
809
+ }
810
+
811
+ // 2. File read
812
+ let rawContent;
813
+ try {
814
+ rawContent = fs.readFileSync(configFile, "utf-8");
815
+ } catch (err) {
816
+ return {
817
+ mode: "workspace",
818
+ config: null,
819
+ error: {
820
+ code: "WORKSPACE_FILE_READ_ERROR",
821
+ message: `Cannot read workspace config file: ${err.message}`,
822
+ },
823
+ };
824
+ }
825
+
826
+ // 3. YAML parse using lightweight line-based extraction
827
+ // (avoids importing the yaml module for CLI startup speed)
828
+ let parsed;
829
+ try {
830
+ parsed = parseWorkspaceYaml(rawContent);
831
+ } catch (err) {
832
+ return {
833
+ mode: "workspace",
834
+ config: null,
835
+ error: {
836
+ code: "WORKSPACE_FILE_PARSE_ERROR",
837
+ message: `Cannot parse workspace config: ${err.message}`,
838
+ },
839
+ };
840
+ }
841
+
842
+ // 4. Schema validation: repos map present and non-empty
843
+ if (!parsed.repos || Object.keys(parsed.repos).length === 0) {
844
+ return {
845
+ mode: "workspace",
846
+ config: null,
847
+ error: {
848
+ code: "WORKSPACE_SCHEMA_INVALID",
849
+ message: "Workspace config must define at least one repo under 'repos'.",
850
+ },
851
+ };
852
+ }
853
+
854
+ // 5. Schema validation: routing present
855
+ if (!parsed.routing || (!parsed.routing.default_repo && !parsed.routing.tasks_root)) {
856
+ return {
857
+ mode: "workspace",
858
+ config: null,
859
+ error: {
860
+ code: "WORKSPACE_SCHEMA_INVALID",
861
+ message: "Workspace config must contain a 'routing' mapping with default_repo and tasks_root.",
862
+ },
863
+ };
864
+ }
865
+
866
+ // 6. Per-repo validation: path field present
867
+ const repoKeys = Object.keys(parsed.repos).sort();
868
+ for (const repoId of repoKeys) {
869
+ const repo = parsed.repos[repoId];
870
+ if (!repo.path) {
871
+ return {
872
+ mode: "workspace",
873
+ config: null,
874
+ error: {
875
+ code: "WORKSPACE_REPO_PATH_MISSING",
876
+ message: `Repo '${repoId}' is missing a 'path' field.`,
877
+ },
878
+ };
879
+ }
880
+ }
881
+
882
+ // 7. Routing validation
883
+ if (!parsed.routing.tasks_root) {
884
+ return {
885
+ mode: "workspace",
886
+ config: null,
887
+ error: {
888
+ code: "WORKSPACE_MISSING_TASKS_ROOT",
889
+ message: "Workspace config 'routing.tasks_root' is missing or empty.",
890
+ },
891
+ };
892
+ }
893
+
894
+ if (!parsed.routing.default_repo) {
895
+ return {
896
+ mode: "workspace",
897
+ config: null,
898
+ error: {
899
+ code: "WORKSPACE_MISSING_DEFAULT_REPO",
900
+ message: "Workspace config 'routing.default_repo' is missing or empty.",
901
+ },
902
+ };
903
+ }
904
+
905
+ const defaultRepoId = parsed.routing.default_repo;
906
+ if (!parsed.repos[defaultRepoId]) {
907
+ const available = Object.keys(parsed.repos).join(", ");
908
+ return {
909
+ mode: "workspace",
910
+ config: null,
911
+ error: {
912
+ code: "WORKSPACE_DEFAULT_REPO_NOT_FOUND",
913
+ message: `routing.default_repo '${defaultRepoId}' does not match any repo ID. Available: ${available}`,
914
+ },
915
+ };
916
+ }
917
+
918
+ // Valid workspace config — build summary for doctor display
919
+ return {
920
+ mode: "workspace",
921
+ config: {
922
+ repos: parsed.repos,
923
+ routing: {
924
+ tasksRoot: parsed.routing.tasks_root,
925
+ defaultRepo: defaultRepoId,
926
+ },
927
+ configPath: configFile,
928
+ },
929
+ error: null,
930
+ };
931
+ }
932
+
933
+ /**
934
+ * Lightweight YAML parser for workspace config.
935
+ * Extracts repos (id → { path, default_branch }) and routing fields.
936
+ * Does NOT handle all YAML — only the workspace config subset.
937
+ */
938
+ function parseWorkspaceYaml(raw) {
939
+ const lines = raw.split(/\r?\n/);
940
+ const result = { repos: {}, routing: {} };
941
+ let section = null; // "repos" | "routing" | null
942
+ let currentRepoId = null; // current repo being parsed
943
+
944
+ for (const line of lines) {
945
+ const trimmed = line.trim();
946
+ if (!trimmed || trimmed.startsWith("#")) continue;
947
+
948
+ // Top-level keys
949
+ if (/^repos\s*:\s*$/.test(trimmed)) {
950
+ section = "repos";
951
+ currentRepoId = null;
952
+ continue;
953
+ }
954
+ if (/^routing\s*:\s*$/.test(trimmed)) {
955
+ section = "routing";
956
+ currentRepoId = null;
957
+ continue;
958
+ }
959
+ // Any other top-level key ends current section
960
+ if (/^[a-z_]+\s*:/.test(line) && !line.startsWith(" ") && !line.startsWith("\t")) {
961
+ section = null;
962
+ currentRepoId = null;
963
+ continue;
964
+ }
965
+
966
+ if (section === "repos") {
967
+ // Repo ID line (2-space indent): " api:"
968
+ const repoIdMatch = line.match(/^ ([a-z0-9][a-z0-9_-]*)\s*:\s*$/);
969
+ if (repoIdMatch) {
970
+ currentRepoId = repoIdMatch[1];
971
+ result.repos[currentRepoId] = {};
972
+ continue;
973
+ }
974
+ // Repo property lines (4-space indent): " path: ../api-repo"
975
+ if (currentRepoId) {
976
+ const propMatch = line.match(/^\s{4}(\w+)\s*:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/);
977
+ if (propMatch) {
978
+ result.repos[currentRepoId][propMatch[1]] = propMatch[2].trim();
979
+ }
980
+ }
981
+ }
982
+
983
+ if (section === "routing") {
984
+ // Routing property lines (2-space indent): " default_repo: api"
985
+ const propMatch = line.match(/^\s{2}(\w+)\s*:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/);
986
+ if (propMatch) {
987
+ result.routing[propMatch[1]] = propMatch[2].trim();
988
+ }
989
+ }
990
+ }
991
+
992
+ return result;
993
+ }
994
+
768
995
  // ─── doctor ─────────────────────────────────────────────────────────────────
769
996
 
770
997
  function cmdDoctor() {
@@ -807,7 +1034,66 @@ function cmdDoctor() {
807
1034
  const installType = isProjectLocal ? "project-local" : "global";
808
1035
  console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`);
809
1036
 
810
- // Check project config
1037
+ // Detect workspace mode
1038
+ const wsResult = loadWorkspaceConfigForDoctor(projectRoot);
1039
+ const isWorkspaceMode = wsResult.mode === "workspace";
1040
+
1041
+ if (isWorkspaceMode) {
1042
+ console.log();
1043
+ if (wsResult.error) {
1044
+ // Config present but invalid — report as failure
1045
+ const codeHint = wsResult.error.code ? ` [${wsResult.error.code}]` : "";
1046
+ console.log(` ${FAIL} workspace mode detected but config is invalid${codeHint}`);
1047
+ console.log(` ${c.dim}${wsResult.error.message}${c.reset}`);
1048
+ console.log(` ${c.dim}→ Fix .pi/taskplane-workspace.yaml or remove it to use repo mode${c.reset}`);
1049
+ issues++;
1050
+ } else {
1051
+ // Valid workspace config — show summary banner
1052
+ const cfg = wsResult.config;
1053
+ const repoIds = Object.keys(cfg.repos);
1054
+ const repoCount = repoIds.length;
1055
+ const defaultRepo = cfg.routing.defaultRepo;
1056
+ const tasksRoot = cfg.routing.tasksRoot;
1057
+ console.log(` ${OK} workspace mode ${c.dim}(${repoCount} repo${repoCount !== 1 ? "s" : ""}, default: ${defaultRepo})${c.reset}`);
1058
+ console.log(` ${c.dim}repos: ${repoIds.join(", ")}${c.reset}`);
1059
+ console.log(` ${c.dim}tasks_root: ${tasksRoot}${c.reset}`);
1060
+ }
1061
+ }
1062
+
1063
+ // Step 1: Validate repo topology (workspace mode + valid config only)
1064
+ if (isWorkspaceMode && wsResult.config) {
1065
+ console.log();
1066
+ const repoIds = Object.keys(wsResult.config.repos).sort();
1067
+ for (const repoId of repoIds) {
1068
+ const repo = wsResult.config.repos[repoId];
1069
+ const resolvedPath = path.resolve(projectRoot, repo.path);
1070
+
1071
+ // Check path exists on disk
1072
+ if (!fs.existsSync(resolvedPath)) {
1073
+ console.log(` ${FAIL} repo: ${repoId} — path not found: ${resolvedPath} [WORKSPACE_REPO_PATH_NOT_FOUND]`);
1074
+ console.log(` ${c.dim}→ Check repos.${repoId}.path in .pi/taskplane-workspace.yaml${c.reset}`);
1075
+ issues++;
1076
+ continue;
1077
+ }
1078
+
1079
+ // Check path is a git repository
1080
+ try {
1081
+ execSync("git rev-parse --git-dir", {
1082
+ cwd: resolvedPath,
1083
+ stdio: ["pipe", "pipe", "pipe"],
1084
+ timeout: 5000,
1085
+ });
1086
+ console.log(` ${OK} repo: ${repoId} ${c.dim}(${resolvedPath})${c.reset}`);
1087
+ } catch {
1088
+ console.log(` ${FAIL} repo: ${repoId} — not a git repository: ${resolvedPath} [WORKSPACE_REPO_NOT_GIT]`);
1089
+ console.log(` ${c.dim}→ Run: git init ${resolvedPath}${c.reset}`);
1090
+ console.log(` ${c.dim} or fix repos.${repoId}.path in .pi/taskplane-workspace.yaml${c.reset}`);
1091
+ issues++;
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // Check project config (common — both modes)
811
1097
  console.log();
812
1098
  const configFiles = [
813
1099
  { path: ".pi/task-runner.yaml", required: true },
@@ -818,20 +1104,30 @@ function cmdDoctor() {
818
1104
  { path: ".pi/taskplane.json", required: false },
819
1105
  ];
820
1106
 
1107
+ // In workspace mode, include workspace config in the config files check
1108
+ if (isWorkspaceMode && !wsResult.error) {
1109
+ configFiles.push({ path: ".pi/taskplane-workspace.yaml", required: true });
1110
+ }
1111
+
1112
+ let missingRequiredConfigs = 0;
821
1113
  for (const { path: relPath, required } of configFiles) {
822
1114
  const exists = fs.existsSync(path.join(projectRoot, relPath));
823
1115
  if (exists) {
824
1116
  console.log(` ${OK} ${relPath} exists`);
825
1117
  } else if (required) {
826
1118
  console.log(` ${FAIL} ${relPath} missing`);
1119
+ missingRequiredConfigs++;
827
1120
  issues++;
828
1121
  } else {
829
1122
  console.log(` ${WARN} ${relPath} missing ${c.dim}(optional)${c.reset}`);
830
1123
  }
831
1124
  }
1125
+ if (missingRequiredConfigs > 0) {
1126
+ console.log(` ${c.dim}→ Run: taskplane init${c.reset}`);
1127
+ }
832
1128
 
833
1129
  // Check task areas from config
834
- const { paths: taskAreaPaths, contexts: taskAreaContexts } = discoverTaskAreaMetadata(projectRoot);
1130
+ const { paths: taskAreaPaths, contexts: taskAreaContexts, areaRepoIds } = discoverTaskAreaMetadata(projectRoot);
835
1131
  if (taskAreaPaths.length > 0) {
836
1132
  console.log();
837
1133
  for (const areaPath of taskAreaPaths) {
@@ -854,6 +1150,22 @@ function cmdDoctor() {
854
1150
  }
855
1151
  }
856
1152
 
1153
+ // Validate area repo_id routing targets (workspace mode + valid config only)
1154
+ if (isWorkspaceMode && wsResult.config && Object.keys(areaRepoIds).length > 0) {
1155
+ const knownRepoIds = Object.keys(wsResult.config.repos).sort();
1156
+ const areaNames = Object.keys(areaRepoIds).sort();
1157
+ for (const areaName of areaNames) {
1158
+ const repoId = areaRepoIds[areaName];
1159
+ if (knownRepoIds.includes(repoId)) {
1160
+ console.log(` ${OK} area '${areaName}' repo_id: ${repoId}`);
1161
+ } else {
1162
+ console.log(` ${FAIL} area '${areaName}' repo_id '${repoId}' does not match any workspace repo [AREA_REPO_ID_UNKNOWN]`);
1163
+ console.log(` ${c.dim}→ Available repos: ${knownRepoIds.join(", ")}. Fix repo_id in .pi/task-runner.yaml${c.reset}`);
1164
+ issues++;
1165
+ }
1166
+ }
1167
+ }
1168
+
857
1169
  console.log();
858
1170
  if (issues === 0) {
859
1171
  console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);