taskplane 0.1.15 → 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 +317 -5
- package/extensions/taskplane/abort.ts +461 -466
- package/extensions/taskplane/config.ts +17 -12
- package/extensions/taskplane/discovery.ts +168 -32
- package/extensions/taskplane/engine.ts +22 -12
- package/extensions/taskplane/execution.ts +108 -46
- package/extensions/taskplane/extension.ts +780 -693
- package/extensions/taskplane/index.ts +23 -22
- package/extensions/taskplane/messages.ts +146 -134
- package/extensions/taskplane/resume.ts +9 -3
- package/extensions/taskplane/types.ts +238 -1
- package/extensions/taskplane/workspace.ts +382 -0
- package/extensions/taskplane/worktree.ts +107 -6
- package/package.json +1 -1
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
|
-
//
|
|
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`);
|