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.
package/bin/taskplane.mjs CHANGED
@@ -212,7 +212,6 @@ orchestrator:
212
212
  max_lanes: ${vars.max_lanes}
213
213
  worktree_location: "subdirectory"
214
214
  worktree_prefix: "${vars.worktree_prefix}"
215
- integration_branch: "${vars.integration_branch}"
216
215
  batch_id_format: "timestamp"
217
216
  spawn_mode: "subprocess"
218
217
  tmux_prefix: "${vars.tmux_prefix}"
@@ -293,15 +292,17 @@ async function autoCommitTaskFiles(projectRoot, tasksRoot) {
293
292
 
294
293
  function discoverTaskAreaMetadata(projectRoot) {
295
294
  const runnerPath = path.join(projectRoot, ".pi", "task-runner.yaml");
296
- if (!fs.existsSync(runnerPath)) return { paths: [], contexts: [] };
295
+ if (!fs.existsSync(runnerPath)) return { paths: [], contexts: [], areaRepoIds: {} };
297
296
 
298
297
  const raw = readYaml(runnerPath);
299
- if (!raw) return { paths: [], contexts: [] };
298
+ if (!raw) return { paths: [], contexts: [], areaRepoIds: {} };
300
299
 
301
300
  const lines = raw.split(/\r?\n/);
302
301
  let inTaskAreas = false;
302
+ let currentAreaName = null;
303
303
  const paths = new Set();
304
304
  const contexts = new Set();
305
+ const areaRepoIds = {}; // area name → repo_id (only areas that declare one)
305
306
 
306
307
  for (const line of lines) {
307
308
  const trimmed = line.trim();
@@ -318,6 +319,13 @@ function discoverTaskAreaMetadata(projectRoot) {
318
319
  break;
319
320
  }
320
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
+
321
329
  const pathMatch = line.match(/^\s{4}path:\s*["']?([^"'\n#]+)["']?\s*(?:#.*)?$/);
322
330
  if (pathMatch?.[1]) {
323
331
  paths.add(pathMatch[1].trim());
@@ -327,9 +335,18 @@ function discoverTaskAreaMetadata(projectRoot) {
327
335
  if (contextMatch?.[1]) {
328
336
  contexts.add(contextMatch[1].trim());
329
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
+ }
330
347
  }
331
348
 
332
- return { paths: [...paths], contexts: [...contexts] };
349
+ return { paths: [...paths], contexts: [...contexts], areaRepoIds };
333
350
  }
334
351
 
335
352
  function discoverTaskAreaPaths(projectRoot) {
@@ -707,7 +724,6 @@ function getPresetVars(preset, projectRoot, tasksRootOverride = null) {
707
724
  const { test: test_cmd, build: build_cmd } = detectStack(projectRoot);
708
725
  return {
709
726
  project_name: dirName,
710
- integration_branch: "main",
711
727
  max_lanes: 3,
712
728
  worktree_prefix: `${slug}-wt`,
713
729
  tmux_prefix: `${slug}-orch`,
@@ -725,7 +741,6 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
725
741
  const detected = detectStack(projectRoot);
726
742
 
727
743
  const project_name = await ask("Project name", dirName);
728
- const integration_branch = await ask("Default branch (fallback — orchestrator uses your current branch at runtime)", "main");
729
744
  const max_lanes = parseInt(await ask("Max parallel lanes", "3")) || 3;
730
745
  const tasks_root = tasksRootOverride || await ask("Tasks directory", "taskplane-tasks");
731
746
  const default_area = await ask("Default area name", "general");
@@ -736,7 +751,6 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
736
751
  const slug = slugify(project_name);
737
752
  return {
738
753
  project_name,
739
- integration_branch,
740
754
  max_lanes,
741
755
  worktree_prefix: `${slug}-wt`,
742
756
  tmux_prefix: `${slug}-orch`,
@@ -769,6 +783,215 @@ function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
769
783
  console.log();
770
784
  }
771
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
+
772
995
  // ─── doctor ─────────────────────────────────────────────────────────────────
773
996
 
774
997
  function cmdDoctor() {
@@ -811,7 +1034,66 @@ function cmdDoctor() {
811
1034
  const installType = isProjectLocal ? "project-local" : "global";
812
1035
  console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`);
813
1036
 
814
- // 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)
815
1097
  console.log();
816
1098
  const configFiles = [
817
1099
  { path: ".pi/task-runner.yaml", required: true },
@@ -822,20 +1104,30 @@ function cmdDoctor() {
822
1104
  { path: ".pi/taskplane.json", required: false },
823
1105
  ];
824
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;
825
1113
  for (const { path: relPath, required } of configFiles) {
826
1114
  const exists = fs.existsSync(path.join(projectRoot, relPath));
827
1115
  if (exists) {
828
1116
  console.log(` ${OK} ${relPath} exists`);
829
1117
  } else if (required) {
830
1118
  console.log(` ${FAIL} ${relPath} missing`);
1119
+ missingRequiredConfigs++;
831
1120
  issues++;
832
1121
  } else {
833
1122
  console.log(` ${WARN} ${relPath} missing ${c.dim}(optional)${c.reset}`);
834
1123
  }
835
1124
  }
1125
+ if (missingRequiredConfigs > 0) {
1126
+ console.log(` ${c.dim}→ Run: taskplane init${c.reset}`);
1127
+ }
836
1128
 
837
1129
  // Check task areas from config
838
- const { paths: taskAreaPaths, contexts: taskAreaContexts } = discoverTaskAreaMetadata(projectRoot);
1130
+ const { paths: taskAreaPaths, contexts: taskAreaContexts, areaRepoIds } = discoverTaskAreaMetadata(projectRoot);
839
1131
  if (taskAreaPaths.length > 0) {
840
1132
  console.log();
841
1133
  for (const areaPath of taskAreaPaths) {
@@ -858,6 +1150,22 @@ function cmdDoctor() {
858
1150
  }
859
1151
  }
860
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
+
861
1169
  console.log();
862
1170
  if (issues === 0) {
863
1171
  console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);