taskplane 0.3.0 → 0.4.0
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/README.md +14 -5
- package/bin/gitignore-patterns.mjs +78 -0
- package/bin/taskplane.mjs +1356 -34
- package/dashboard/server.cjs +13 -1
- package/extensions/task-runner.ts +212 -57
- package/extensions/taskplane/config-loader.ts +860 -0
- package/extensions/taskplane/config-schema.ts +468 -0
- package/extensions/taskplane/config.ts +37 -93
- package/extensions/taskplane/engine.ts +2 -0
- package/extensions/taskplane/extension.ts +19 -0
- package/extensions/taskplane/merge.ts +12 -4
- package/extensions/taskplane/resume.ts +23 -14
- package/extensions/taskplane/settings-tui.ts +1387 -0
- package/extensions/taskplane/types.ts +81 -1
- package/extensions/taskplane/workspace.ts +204 -10
- package/package.json +1 -1
- package/skills/create-taskplane-task/SKILL.md +7 -5
- package/skills/create-taskplane-task/references/prompt-template.md +4 -3
- package/templates/agents/local/task-merger.md +27 -0
- package/templates/agents/local/task-reviewer.md +29 -0
- package/templates/agents/local/task-worker.md +30 -0
- package/templates/agents/task-worker.md +45 -31
- package/templates/config/task-orchestrator.yaml +3 -0
package/bin/taskplane.mjs
CHANGED
|
@@ -26,7 +26,15 @@ import fs from "node:fs";
|
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import readline from "node:readline";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { execSync, spawn } from "node:child_process";
|
|
29
|
+
import { execSync, execFileSync, spawn } from "node:child_process";
|
|
30
|
+
import {
|
|
31
|
+
TASKPLANE_GITIGNORE_HEADER,
|
|
32
|
+
TASKPLANE_GITIGNORE_NPM_HEADER,
|
|
33
|
+
TASKPLANE_GITIGNORE_ENTRIES,
|
|
34
|
+
TASKPLANE_GITIGNORE_NPM_ENTRIES,
|
|
35
|
+
ALL_GITIGNORE_PATTERNS,
|
|
36
|
+
patternToRegex,
|
|
37
|
+
} from "./gitignore-patterns.mjs";
|
|
30
38
|
|
|
31
39
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
32
40
|
|
|
@@ -225,7 +233,7 @@ orchestrator:
|
|
|
225
233
|
worktree_location: "subdirectory"
|
|
226
234
|
worktree_prefix: "${vars.worktree_prefix}"
|
|
227
235
|
batch_id_format: "timestamp"
|
|
228
|
-
spawn_mode: "
|
|
236
|
+
spawn_mode: "${vars.spawn_mode}"
|
|
229
237
|
tmux_prefix: "${vars.tmux_prefix}"
|
|
230
238
|
|
|
231
239
|
dependencies:
|
|
@@ -262,6 +270,93 @@ monitoring:
|
|
|
262
270
|
`;
|
|
263
271
|
}
|
|
264
272
|
|
|
273
|
+
function buildTestingCommands(vars) {
|
|
274
|
+
const commands = {};
|
|
275
|
+
if (vars.test_cmd) commands.unit = vars.test_cmd;
|
|
276
|
+
if (vars.build_cmd) commands.build = vars.build_cmd;
|
|
277
|
+
return commands;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function generateProjectConfig(vars) {
|
|
281
|
+
return {
|
|
282
|
+
configVersion: 1,
|
|
283
|
+
taskRunner: {
|
|
284
|
+
project: { name: vars.project_name, description: "" },
|
|
285
|
+
paths: { tasks: vars.tasks_root },
|
|
286
|
+
testing: { commands: buildTestingCommands(vars) },
|
|
287
|
+
standards: { docs: [], rules: [] },
|
|
288
|
+
standardsOverrides: {},
|
|
289
|
+
worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "off" },
|
|
290
|
+
reviewer: { model: "openai/gpt-5.3-codex", tools: "read,bash,grep,find,ls", thinking: "on" },
|
|
291
|
+
context: {
|
|
292
|
+
workerContextWindow: 200000,
|
|
293
|
+
warnPercent: 70,
|
|
294
|
+
killPercent: 85,
|
|
295
|
+
maxWorkerIterations: 20,
|
|
296
|
+
maxReviewCycles: 2,
|
|
297
|
+
noProgressLimit: 3,
|
|
298
|
+
},
|
|
299
|
+
taskAreas: {
|
|
300
|
+
[vars.default_area]: {
|
|
301
|
+
path: vars.tasks_root,
|
|
302
|
+
prefix: vars.default_prefix,
|
|
303
|
+
context: `${vars.tasks_root}/CONTEXT.md`,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
referenceDocs: {},
|
|
307
|
+
neverLoad: [],
|
|
308
|
+
selfDocTargets: {},
|
|
309
|
+
protectedDocs: [],
|
|
310
|
+
},
|
|
311
|
+
orchestrator: {
|
|
312
|
+
orchestrator: {
|
|
313
|
+
maxLanes: vars.max_lanes,
|
|
314
|
+
worktreeLocation: "subdirectory",
|
|
315
|
+
worktreePrefix: vars.worktree_prefix,
|
|
316
|
+
batchIdFormat: "timestamp",
|
|
317
|
+
spawnMode: vars.spawn_mode,
|
|
318
|
+
tmuxPrefix: vars.tmux_prefix,
|
|
319
|
+
operatorId: "",
|
|
320
|
+
},
|
|
321
|
+
dependencies: { source: "prompt", cache: true },
|
|
322
|
+
assignment: { strategy: "affinity-first", sizeWeights: { S: 1, M: 2, L: 4 } },
|
|
323
|
+
preWarm: { autoDetect: false, commands: {}, always: [] },
|
|
324
|
+
merge: {
|
|
325
|
+
model: "",
|
|
326
|
+
tools: "read,write,edit,bash,grep,find,ls",
|
|
327
|
+
verify: [],
|
|
328
|
+
order: "fewest-files-first",
|
|
329
|
+
timeoutMinutes: 10,
|
|
330
|
+
},
|
|
331
|
+
failure: {
|
|
332
|
+
onTaskFailure: "skip-dependents",
|
|
333
|
+
onMergeFailure: "pause",
|
|
334
|
+
stallTimeout: 30,
|
|
335
|
+
maxWorkerMinutes: 30,
|
|
336
|
+
abortGracePeriod: 60,
|
|
337
|
+
},
|
|
338
|
+
monitoring: { pollInterval: 5 },
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function generateWorkspaceYaml(repoNames, defaultRepo, tasksRoot) {
|
|
344
|
+
const reposBlock = repoNames
|
|
345
|
+
.map((name) => ` ${name}:\n path: "${name}"`)
|
|
346
|
+
.join("\n");
|
|
347
|
+
return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${tasksRoot}"\n default_repo: "${defaultRepo}"\n`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function readWorkspaceJson(configRepoRoot) {
|
|
351
|
+
const workspaceJsonPath = path.join(configRepoRoot, ".taskplane", "workspace.json");
|
|
352
|
+
if (!fs.existsSync(workspaceJsonPath)) return null;
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(fs.readFileSync(workspaceJsonPath, "utf-8"));
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
265
360
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
266
361
|
// COMMANDS
|
|
267
362
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
@@ -302,8 +397,8 @@ async function autoCommitTaskFiles(projectRoot, tasksRoot) {
|
|
|
302
397
|
}
|
|
303
398
|
}
|
|
304
399
|
|
|
305
|
-
function discoverTaskAreaMetadata(projectRoot) {
|
|
306
|
-
const runnerPath = path.join(
|
|
400
|
+
function discoverTaskAreaMetadata(projectRoot, configRoot = projectRoot, configPrefix = ".pi") {
|
|
401
|
+
const runnerPath = path.join(configRoot, configPrefix, "task-runner.yaml");
|
|
307
402
|
if (!fs.existsSync(runnerPath)) return { paths: [], contexts: [], areaRepoIds: {} };
|
|
308
403
|
|
|
309
404
|
const raw = readYaml(runnerPath);
|
|
@@ -562,6 +657,358 @@ async function cmdUninstall(args) {
|
|
|
562
657
|
}
|
|
563
658
|
}
|
|
564
659
|
|
|
660
|
+
// ─── Gitignore Enforcement ──────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
// Gitignore constants and patternToRegex imported from ./gitignore-patterns.mjs
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Ensure required Taskplane gitignore entries exist in the project's .gitignore.
|
|
666
|
+
* Creates the file if it doesn't exist. Skips entries that already exist.
|
|
667
|
+
* Returns { created: boolean, added: string[], skipped: string[] }.
|
|
668
|
+
*
|
|
669
|
+
* @param {string} projectRoot - Root directory containing (or to contain) .gitignore
|
|
670
|
+
* @param {object} options
|
|
671
|
+
* @param {boolean} options.dryRun - If true, don't modify files
|
|
672
|
+
* @param {string} [options.prefix] - Optional prefix for entries (e.g., ".taskplane/" for workspace mode)
|
|
673
|
+
*/
|
|
674
|
+
function ensureGitignoreEntries(projectRoot, { dryRun = false, prefix = "" } = {}) {
|
|
675
|
+
const gitignorePath = path.join(projectRoot, ".gitignore");
|
|
676
|
+
const fileExists = fs.existsSync(gitignorePath);
|
|
677
|
+
const existingContent = fileExists ? fs.readFileSync(gitignorePath, "utf-8") : "";
|
|
678
|
+
const existingLines = new Set(existingContent.split(/\r?\n/).map(l => l.trim()));
|
|
679
|
+
|
|
680
|
+
const allEntries = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES];
|
|
681
|
+
const added = [];
|
|
682
|
+
const skipped = [];
|
|
683
|
+
|
|
684
|
+
for (const entry of allEntries) {
|
|
685
|
+
const prefixedEntry = prefix ? `${prefix}${entry}` : entry;
|
|
686
|
+
if (existingLines.has(prefixedEntry)) {
|
|
687
|
+
skipped.push(prefixedEntry);
|
|
688
|
+
} else {
|
|
689
|
+
added.push(prefixedEntry);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (added.length === 0) {
|
|
694
|
+
return { created: false, added: [], skipped };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (!dryRun) {
|
|
698
|
+
// Build the block of new entries with headers
|
|
699
|
+
const runtimeAdded = added.filter(e => !e.endsWith("npm/"));
|
|
700
|
+
const npmAdded = added.filter(e => e.endsWith("npm/"));
|
|
701
|
+
const newLines = [];
|
|
702
|
+
|
|
703
|
+
if (runtimeAdded.length > 0) {
|
|
704
|
+
// Only add header if it's not already present
|
|
705
|
+
const headerToCheck = prefix
|
|
706
|
+
? TASKPLANE_GITIGNORE_HEADER
|
|
707
|
+
: TASKPLANE_GITIGNORE_HEADER;
|
|
708
|
+
if (!existingLines.has(headerToCheck)) {
|
|
709
|
+
newLines.push(TASKPLANE_GITIGNORE_HEADER);
|
|
710
|
+
}
|
|
711
|
+
newLines.push(...runtimeAdded);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (npmAdded.length > 0) {
|
|
715
|
+
if (!existingLines.has(TASKPLANE_GITIGNORE_NPM_HEADER)) {
|
|
716
|
+
if (newLines.length > 0) newLines.push("");
|
|
717
|
+
newLines.push(TASKPLANE_GITIGNORE_NPM_HEADER);
|
|
718
|
+
}
|
|
719
|
+
newLines.push(...npmAdded);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const blockText = newLines.join("\n") + "\n";
|
|
723
|
+
|
|
724
|
+
if (fileExists) {
|
|
725
|
+
// Append to existing file with a blank line separator
|
|
726
|
+
const separator = existingContent.endsWith("\n") ? "\n" : "\n\n";
|
|
727
|
+
fs.appendFileSync(gitignorePath, separator + blockText, "utf-8");
|
|
728
|
+
} else {
|
|
729
|
+
fs.writeFileSync(gitignorePath, blockText, "utf-8");
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return { created: !fileExists, added, skipped };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// patternToRegex imported from ./gitignore-patterns.mjs
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Check for tracked runtime artifacts and offer to untrack them.
|
|
740
|
+
* Uses `git ls-files` to find tracked files that match gitignore patterns.
|
|
741
|
+
* Runs `git rm --cached` to untrack (files remain on disk).
|
|
742
|
+
*
|
|
743
|
+
* Isolation: This function commits or stashes nothing. It only removes files
|
|
744
|
+
* from the index. The caller is responsible for ensuring this runs BEFORE
|
|
745
|
+
* autoCommitTaskFiles() so the removals don't get bundled into unrelated commits.
|
|
746
|
+
*
|
|
747
|
+
* @param {string} projectRoot - Git repo root
|
|
748
|
+
* @param {object} options
|
|
749
|
+
* @param {boolean} options.dryRun - If true, report but don't modify index
|
|
750
|
+
* @param {boolean} options.interactive - If false, skip prompt and don't untrack
|
|
751
|
+
* @param {string} options.prefix - Path prefix for workspace-scoped scanning (e.g., ".taskplane/")
|
|
752
|
+
*/
|
|
753
|
+
async function detectAndOfferUntrackArtifacts(projectRoot, { dryRun = false, interactive = true, prefix = "" } = {}) {
|
|
754
|
+
// Only run in a git repo
|
|
755
|
+
if (!isInsideGitRepo(projectRoot)) return { found: [], untracked: false };
|
|
756
|
+
|
|
757
|
+
// Get list of tracked files under the relevant directories
|
|
758
|
+
// For workspace mode (prefix=".taskplane/"), scan .taskplane/.pi/ and .taskplane/.worktrees/
|
|
759
|
+
// For repo mode (no prefix), scan .pi/ and .worktrees/
|
|
760
|
+
const scanDirs = prefix
|
|
761
|
+
? [`${prefix}.pi/`, `${prefix}.worktrees/`]
|
|
762
|
+
: [".pi/", ".worktrees/"];
|
|
763
|
+
|
|
764
|
+
let trackedFiles;
|
|
765
|
+
try {
|
|
766
|
+
const raw = execFileSync("git", ["ls-files", "--", ...scanDirs], {
|
|
767
|
+
cwd: projectRoot,
|
|
768
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
769
|
+
timeout: 10000,
|
|
770
|
+
}).toString().trim();
|
|
771
|
+
trackedFiles = raw ? raw.split(/\r?\n/) : [];
|
|
772
|
+
} catch {
|
|
773
|
+
return { found: [], untracked: false };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (trackedFiles.length === 0) return { found: [], untracked: false };
|
|
777
|
+
|
|
778
|
+
// Build regex patterns for matching (with prefix if workspace-scoped)
|
|
779
|
+
const prefixedPatterns = prefix
|
|
780
|
+
? ALL_GITIGNORE_PATTERNS.map(p => `${prefix}${p}`)
|
|
781
|
+
: ALL_GITIGNORE_PATTERNS;
|
|
782
|
+
const patterns = prefixedPatterns.map(p => patternToRegex(p));
|
|
783
|
+
|
|
784
|
+
// Find tracked files that match runtime artifact patterns
|
|
785
|
+
const matchedFiles = trackedFiles.filter(file => {
|
|
786
|
+
return patterns.some(regex => regex.test(file));
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
if (matchedFiles.length === 0) return { found: [], untracked: false };
|
|
790
|
+
|
|
791
|
+
// Report findings
|
|
792
|
+
console.log(`\n ${WARN} Found runtime artifacts tracked by git:`);
|
|
793
|
+
for (const file of matchedFiles) {
|
|
794
|
+
console.log(` ${file}`);
|
|
795
|
+
}
|
|
796
|
+
console.log();
|
|
797
|
+
console.log(` These files contain machine-specific state that will cause problems`);
|
|
798
|
+
console.log(` for other team members.`);
|
|
799
|
+
|
|
800
|
+
if (dryRun) {
|
|
801
|
+
console.log(` ${c.dim}(dry run — would offer to untrack these files)${c.reset}`);
|
|
802
|
+
return { found: matchedFiles, untracked: false };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (!interactive) {
|
|
806
|
+
console.log(` ${c.dim}Run: git rm --cached ${matchedFiles.join(" ")}${c.reset}`);
|
|
807
|
+
return { found: matchedFiles, untracked: false };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const doUntrack = await confirm(" Untrack them? (files stay on disk, become gitignored)", true);
|
|
811
|
+
if (!doUntrack) {
|
|
812
|
+
console.log(` ${c.dim}Skipped. You can untrack later with:${c.reset}`);
|
|
813
|
+
console.log(` ${c.dim}git rm --cached ${matchedFiles.join(" ")}${c.reset}`);
|
|
814
|
+
return { found: matchedFiles, untracked: false };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Untrack: git rm --cached for each file (using execFileSync for shell-safety)
|
|
818
|
+
try {
|
|
819
|
+
execFileSync("git", ["rm", "--cached", "--", ...matchedFiles], {
|
|
820
|
+
cwd: projectRoot,
|
|
821
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
822
|
+
timeout: 10000,
|
|
823
|
+
});
|
|
824
|
+
console.log(` ${OK} Files untracked (still on disk, now gitignored)`);
|
|
825
|
+
return { found: matchedFiles, untracked: true };
|
|
826
|
+
} catch (err) {
|
|
827
|
+
console.log(` ${WARN} Failed to untrack files: ${err.message}`);
|
|
828
|
+
console.log(` ${c.dim}Run manually: git rm --cached ${matchedFiles.join(" ")}${c.reset}`);
|
|
829
|
+
return { found: matchedFiles, untracked: false };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ─── Mode Auto-Detection ────────────────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Check if the given directory is inside a git work tree.
|
|
837
|
+
* Uses `git rev-parse --is-inside-work-tree` for reliability.
|
|
838
|
+
*/
|
|
839
|
+
function isInsideGitRepo(dir) {
|
|
840
|
+
try {
|
|
841
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
842
|
+
cwd: dir,
|
|
843
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
844
|
+
timeout: 5000,
|
|
845
|
+
});
|
|
846
|
+
return true;
|
|
847
|
+
} catch {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Check if the given directory is the root of its own git repository.
|
|
854
|
+
* A directory is a git repo root if it has a `.git` entry (file or directory)
|
|
855
|
+
* AND `git rev-parse --show-toplevel` resolves to that directory.
|
|
856
|
+
* This distinguishes true nested repos from subdirectories of a parent repo.
|
|
857
|
+
*/
|
|
858
|
+
function isGitRepoRoot(dir) {
|
|
859
|
+
const gitEntry = path.join(dir, ".git");
|
|
860
|
+
if (!fs.existsSync(gitEntry)) return false;
|
|
861
|
+
try {
|
|
862
|
+
const toplevel = execSync("git rev-parse --show-toplevel", {
|
|
863
|
+
cwd: dir,
|
|
864
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
865
|
+
timeout: 5000,
|
|
866
|
+
}).toString().trim();
|
|
867
|
+
// Normalize paths for comparison (handles Windows path separators
|
|
868
|
+
// and 8.3 short name mismatches on Windows)
|
|
869
|
+
const normalizedToplevel = path.resolve(toplevel);
|
|
870
|
+
let normalizedDir = path.resolve(dir);
|
|
871
|
+
// On Windows, fs.realpathSync.native resolves 8.3 short names to
|
|
872
|
+
// long names, matching what git returns. Without this, paths like
|
|
873
|
+
// C:\Users\HENRYL~1\... won't match C:\Users\HenryLach\...
|
|
874
|
+
try { normalizedDir = fs.realpathSync.native(normalizedDir); } catch {}
|
|
875
|
+
return normalizedToplevel === normalizedDir;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Scan immediate subdirectories of `dir` for git repositories.
|
|
883
|
+
* Returns an array of subdirectory names that are git repo roots.
|
|
884
|
+
* Only checks one level deep (direct children).
|
|
885
|
+
* Uses `isGitRepoRoot()` to ensure we find actual nested repos,
|
|
886
|
+
* not just subdirectories of the parent repo.
|
|
887
|
+
*/
|
|
888
|
+
function findSubdirectoryGitRepos(dir) {
|
|
889
|
+
const results = [];
|
|
890
|
+
try {
|
|
891
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
892
|
+
for (const entry of entries) {
|
|
893
|
+
if (!entry.isDirectory()) continue;
|
|
894
|
+
// Skip hidden directories and common non-repo directories
|
|
895
|
+
if (entry.name.startsWith(".")) continue;
|
|
896
|
+
if (entry.name === "node_modules") continue;
|
|
897
|
+
const subdir = path.join(dir, entry.name);
|
|
898
|
+
if (isGitRepoRoot(subdir)) {
|
|
899
|
+
results.push(entry.name);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
} catch {
|
|
903
|
+
// If we can't read the directory, return empty
|
|
904
|
+
}
|
|
905
|
+
return results.sort();
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Detect the init mode for the current directory.
|
|
910
|
+
*
|
|
911
|
+
* Detection precedence:
|
|
912
|
+
* 1. Check for existing config (Scenario B — "already initialized")
|
|
913
|
+
* 2. Check git repo topology to determine mode
|
|
914
|
+
*
|
|
915
|
+
* Returns: { mode, subRepos, alreadyInitialized, existingConfigPath }
|
|
916
|
+
* - mode: "repo" | "workspace" | "ambiguous" | "error"
|
|
917
|
+
* - subRepos: string[] — names of subdirectory git repos (for workspace/ambiguous)
|
|
918
|
+
* - alreadyInitialized: boolean — true if config already exists
|
|
919
|
+
* - existingConfigPath: string|null — path to existing config (for Scenario B/D messaging)
|
|
920
|
+
*/
|
|
921
|
+
function detectInitMode(dir) {
|
|
922
|
+
const currentIsGitRepo = isInsideGitRepo(dir);
|
|
923
|
+
const subRepos = findSubdirectoryGitRepos(dir);
|
|
924
|
+
const hasSubRepos = subRepos.length > 0;
|
|
925
|
+
|
|
926
|
+
// Check for existing config in current dir (monorepo Scenario B)
|
|
927
|
+
const hasLocalConfig =
|
|
928
|
+
fs.existsSync(path.join(dir, ".pi", "task-runner.yaml")) ||
|
|
929
|
+
fs.existsSync(path.join(dir, ".pi", "task-orchestrator.yaml")) ||
|
|
930
|
+
fs.existsSync(path.join(dir, ".pi", "taskplane-config.json"));
|
|
931
|
+
|
|
932
|
+
if (currentIsGitRepo && !hasSubRepos) {
|
|
933
|
+
// Clear repo mode (Scenario A or B)
|
|
934
|
+
return {
|
|
935
|
+
mode: "repo",
|
|
936
|
+
subRepos: [],
|
|
937
|
+
alreadyInitialized: hasLocalConfig,
|
|
938
|
+
existingConfigPath: hasLocalConfig ? path.join(dir, ".pi") : null,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (currentIsGitRepo && hasSubRepos) {
|
|
943
|
+
// Ambiguous — git repo that also contains git repo subdirectories
|
|
944
|
+
// Check for workspace-style .taskplane/ in subrepos too (for Scenario D if user picks workspace)
|
|
945
|
+
let workspaceConfigRepo = null;
|
|
946
|
+
for (const repoName of subRepos) {
|
|
947
|
+
const taskplaneDir = path.join(dir, repoName, ".taskplane");
|
|
948
|
+
if (fs.existsSync(taskplaneDir)) {
|
|
949
|
+
workspaceConfigRepo = repoName;
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
mode: "ambiguous",
|
|
955
|
+
subRepos,
|
|
956
|
+
alreadyInitialized: hasLocalConfig,
|
|
957
|
+
existingConfigPath: hasLocalConfig ? path.join(dir, ".pi") : null,
|
|
958
|
+
workspaceConfigRepo,
|
|
959
|
+
workspaceConfigPath: workspaceConfigRepo
|
|
960
|
+
? path.join(dir, workspaceConfigRepo, ".taskplane")
|
|
961
|
+
: null,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (!currentIsGitRepo && hasSubRepos) {
|
|
966
|
+
// Workspace mode (Scenario C or D)
|
|
967
|
+
// Check for existing .taskplane/ in any subdirectory repo (Scenario D)
|
|
968
|
+
let existingConfigRepo = null;
|
|
969
|
+
for (const repoName of subRepos) {
|
|
970
|
+
const taskplaneDir = path.join(dir, repoName, ".taskplane");
|
|
971
|
+
if (fs.existsSync(taskplaneDir)) {
|
|
972
|
+
existingConfigRepo = repoName;
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
mode: "workspace",
|
|
978
|
+
subRepos,
|
|
979
|
+
alreadyInitialized: existingConfigRepo !== null,
|
|
980
|
+
existingConfigPath: existingConfigRepo
|
|
981
|
+
? path.join(dir, existingConfigRepo, ".taskplane")
|
|
982
|
+
: null,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Not a git repo and no git repos in subdirectories → error
|
|
987
|
+
return {
|
|
988
|
+
mode: "error",
|
|
989
|
+
subRepos: [],
|
|
990
|
+
alreadyInitialized: false,
|
|
991
|
+
existingConfigPath: null,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ─── tmux / spawn mode detection ────────────────────────────────────────────
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Detect whether tmux is available and determine the default spawn_mode.
|
|
999
|
+
*
|
|
1000
|
+
* Reusable for both repo mode (Step 3) and workspace mode (Step 4) init.
|
|
1001
|
+
*
|
|
1002
|
+
* @returns {{ spawnMode: string, hasTmux: boolean }}
|
|
1003
|
+
*/
|
|
1004
|
+
function detectSpawnMode() {
|
|
1005
|
+
const hasTmux = commandExists("tmux");
|
|
1006
|
+
return {
|
|
1007
|
+
spawnMode: hasTmux ? "tmux" : "subprocess",
|
|
1008
|
+
hasTmux,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
565
1012
|
// ─── init ───────────────────────────────────────────────────────────────────
|
|
566
1013
|
|
|
567
1014
|
async function cmdInit(args) {
|
|
@@ -606,18 +1053,434 @@ async function cmdInit(args) {
|
|
|
606
1053
|
console.log(` Use --include-examples to scaffold examples into that directory.\n`);
|
|
607
1054
|
}
|
|
608
1055
|
|
|
609
|
-
//
|
|
1056
|
+
// ── Mode auto-detection ──────────────────────────────────────
|
|
1057
|
+
const detection = detectInitMode(projectRoot);
|
|
1058
|
+
const isPreset = preset === "minimal" || preset === "full" || preset === "runner-only";
|
|
1059
|
+
|
|
1060
|
+
// Error path: not a git repo and no git repos found
|
|
1061
|
+
if (detection.mode === "error") {
|
|
1062
|
+
die(
|
|
1063
|
+
"Not a git repo and no git repos found in subdirectories.\n" +
|
|
1064
|
+
" Run from inside a git repository, or from a workspace root\n" +
|
|
1065
|
+
" that contains git repositories as subdirectories."
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Resolve ambiguous mode (git repo + git repo subdirectories)
|
|
1070
|
+
let resolvedMode = detection.mode;
|
|
1071
|
+
if (detection.mode === "ambiguous") {
|
|
1072
|
+
if (isPreset || dryRun) {
|
|
1073
|
+
// Non-interactive: default to repo mode (safe default, no prompt)
|
|
1074
|
+
resolvedMode = "repo";
|
|
1075
|
+
console.log(` ${INFO} Ambiguous layout detected (git repo with git repo subdirectories).`);
|
|
1076
|
+
console.log(` Defaulting to ${c.cyan}repo mode${c.reset} (use interactive mode for workspace).\n`);
|
|
1077
|
+
} else {
|
|
1078
|
+
// Interactive: prompt the user
|
|
1079
|
+
console.log(` ${WARN} This directory is a git repo AND contains git repos as subdirectories.`);
|
|
1080
|
+
console.log(` Subdirectory repos found: ${detection.subRepos.join(", ")}\n`);
|
|
1081
|
+
const modeChoice = await ask(
|
|
1082
|
+
"Mode: (r)epo — treat as single monorepo, or (w)orkspace — treat subdirs as independent repos",
|
|
1083
|
+
"r"
|
|
1084
|
+
);
|
|
1085
|
+
resolvedMode = modeChoice.toLowerCase().startsWith("w") ? "workspace" : "repo";
|
|
1086
|
+
console.log();
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// When ambiguous mode resolves to workspace, use workspace-specific config
|
|
1091
|
+
// detection. The detection.existingConfigPath from ambiguous mode points to
|
|
1092
|
+
// monorepo `.pi/` config, which is irrelevant for workspace Scenario D
|
|
1093
|
+
// (which looks for `.taskplane/` in subrepos).
|
|
1094
|
+
let effectiveAlreadyInitialized = detection.alreadyInitialized;
|
|
1095
|
+
let effectiveConfigPath = detection.existingConfigPath;
|
|
1096
|
+
if (detection.mode === "ambiguous" && resolvedMode === "workspace") {
|
|
1097
|
+
effectiveAlreadyInitialized = detection.workspaceConfigRepo !== null;
|
|
1098
|
+
effectiveConfigPath = detection.workspaceConfigPath || null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Scenario B: existing monorepo config — block reinit unless --force
|
|
1102
|
+
if (effectiveAlreadyInitialized && !force && resolvedMode === "repo") {
|
|
1103
|
+
console.log(` ${INFO} Project already initialized (config exists in .pi/).`);
|
|
1104
|
+
console.log(` Run ${c.cyan}taskplane doctor${c.reset} to verify, or use ${c.cyan}--force${c.reset} to reinitialize.\n`);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Scenario D: existing workspace config found in a subdirectory repo
|
|
1109
|
+
// Create pointer only — skip all Scenario C scaffolding/prompts/gitignore/auto-commit
|
|
1110
|
+
// This is independent of --force: --force only controls pointer overwrite, not Scenario D detection
|
|
1111
|
+
if (effectiveAlreadyInitialized && resolvedMode === "workspace" && effectiveConfigPath) {
|
|
1112
|
+
const configRepo = path.basename(path.dirname(effectiveConfigPath));
|
|
1113
|
+
const configRepoRoot = path.join(projectRoot, configRepo);
|
|
1114
|
+
const existingWorkspaceJson = readWorkspaceJson(configRepoRoot);
|
|
1115
|
+
const workspaceTasksRoot = existingWorkspaceJson?.routing?.tasks_root || "taskplane-tasks";
|
|
1116
|
+
const workspaceDefaultRepo = existingWorkspaceJson?.routing?.default_repo || configRepo;
|
|
1117
|
+
const workspaceRepoNames = Array.from(
|
|
1118
|
+
new Set([
|
|
1119
|
+
...detection.subRepos,
|
|
1120
|
+
...((Array.isArray(existingWorkspaceJson?.repos) ? existingWorkspaceJson.repos : [])
|
|
1121
|
+
.map((repo) => repo?.name)
|
|
1122
|
+
.filter(Boolean)),
|
|
1123
|
+
]),
|
|
1124
|
+
).sort();
|
|
1125
|
+
|
|
1126
|
+
console.log(` ${c.dim}Mode: workspace (${detection.subRepos.length} git repositories found)${c.reset}`);
|
|
1127
|
+
console.log(` ${INFO} Found existing Taskplane config in ${c.cyan}${configRepo}/.taskplane/${c.reset}`);
|
|
1128
|
+
console.log(` Using existing configuration.\n`);
|
|
1129
|
+
|
|
1130
|
+
// ── Pointer idempotency ─────────────────────────────────
|
|
1131
|
+
const pointerPath = path.join(projectRoot, ".pi", "taskplane-pointer.json");
|
|
1132
|
+
const workspaceYamlPath = path.join(projectRoot, ".pi", "taskplane-workspace.yaml");
|
|
1133
|
+
const pointerExists = fs.existsSync(pointerPath);
|
|
1134
|
+
const workspaceYamlExists = fs.existsSync(workspaceYamlPath);
|
|
1135
|
+
|
|
1136
|
+
if (dryRun) {
|
|
1137
|
+
console.log(`${c.bold}Dry run — files that would be created:${c.reset}\n`);
|
|
1138
|
+
if (pointerExists) {
|
|
1139
|
+
console.log(` ${c.yellow}overwrite${c.reset} .pi/taskplane-pointer.json`);
|
|
1140
|
+
} else {
|
|
1141
|
+
console.log(` ${c.green}create${c.reset} .pi/taskplane-pointer.json`);
|
|
1142
|
+
}
|
|
1143
|
+
if (workspaceYamlExists) {
|
|
1144
|
+
console.log(` ${c.dim}skip${c.reset} .pi/taskplane-workspace.yaml (already exists)`);
|
|
1145
|
+
} else {
|
|
1146
|
+
console.log(` ${c.green}create${c.reset} .pi/taskplane-workspace.yaml`);
|
|
1147
|
+
}
|
|
1148
|
+
console.log();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (pointerExists && !force) {
|
|
1153
|
+
let existingPointer = null;
|
|
1154
|
+
try {
|
|
1155
|
+
existingPointer = JSON.parse(fs.readFileSync(pointerPath, "utf-8"));
|
|
1156
|
+
} catch {
|
|
1157
|
+
// Malformed pointer file — treat as invalid, will be overwritten
|
|
1158
|
+
console.log(` ${WARN} .pi/taskplane-pointer.json exists but is malformed — will overwrite.`);
|
|
1159
|
+
}
|
|
1160
|
+
if (existingPointer && existingPointer.config_repo === configRepo && existingPointer.config_path === ".taskplane") {
|
|
1161
|
+
console.log(` ${c.dim}skip${c.reset} .pi/taskplane-pointer.json (already points to ${configRepo}/.taskplane/)`);
|
|
1162
|
+
console.log(`\n${OK} ${c.bold}Workspace already configured.${c.reset}`);
|
|
1163
|
+
console.log(` Run ${c.cyan}taskplane doctor${c.reset} to verify.\n`);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
// Pointer exists but points elsewhere (or was malformed) — prompt to overwrite
|
|
1167
|
+
if (existingPointer && !isPreset) {
|
|
1168
|
+
console.log(` ${WARN} .pi/taskplane-pointer.json already exists (points to ${existingPointer.config_repo}/.taskplane/).`);
|
|
1169
|
+
const proceed = await confirm(" Update pointer to point to " + configRepo + "/.taskplane/?", true);
|
|
1170
|
+
if (!proceed) {
|
|
1171
|
+
console.log(" Aborted.");
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
// Preset/non-interactive or malformed: overwrite silently
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Create pointer file
|
|
1179
|
+
const pointer = {
|
|
1180
|
+
config_repo: configRepo,
|
|
1181
|
+
config_path: ".taskplane",
|
|
1182
|
+
};
|
|
1183
|
+
writeFile(
|
|
1184
|
+
pointerPath,
|
|
1185
|
+
JSON.stringify(pointer, null, 2) + "\n",
|
|
1186
|
+
{ label: ".pi/taskplane-pointer.json" }
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
writeFile(
|
|
1190
|
+
workspaceYamlPath,
|
|
1191
|
+
generateWorkspaceYaml(workspaceRepoNames, workspaceDefaultRepo, workspaceTasksRoot),
|
|
1192
|
+
{ skipIfExists: !force, label: ".pi/taskplane-workspace.yaml" },
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
console.log(`\n${OK} ${c.bold}Workspace pointer created.${c.reset}\n`);
|
|
1196
|
+
console.log(` Config: ${c.cyan}${configRepo}/.taskplane/${c.reset}`);
|
|
1197
|
+
console.log(` Pointer: ${c.cyan}.pi/taskplane-pointer.json${c.reset}`);
|
|
1198
|
+
console.log(` Workspace config: ${c.cyan}.pi/taskplane-workspace.yaml${c.reset}\n`);
|
|
1199
|
+
console.log(`${c.bold}Quick start:${c.reset}`);
|
|
1200
|
+
console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`);
|
|
1201
|
+
console.log(` ${c.cyan}taskplane doctor${c.reset} # verify setup`);
|
|
1202
|
+
console.log();
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Show detected mode
|
|
1207
|
+
if (resolvedMode === "repo") {
|
|
1208
|
+
console.log(` ${c.dim}Mode: repo (standard monorepo)${c.reset}`);
|
|
1209
|
+
} else if (resolvedMode === "workspace") {
|
|
1210
|
+
console.log(` ${c.dim}Mode: workspace (${detection.subRepos.length} git repositories found)${c.reset}`);
|
|
1211
|
+
}
|
|
1212
|
+
console.log();
|
|
1213
|
+
|
|
1214
|
+
// ── Workspace mode: Scenario C (first-time project init) ─────────────
|
|
1215
|
+
if (resolvedMode === "workspace") {
|
|
1216
|
+
// List discovered repos
|
|
1217
|
+
console.log(` Found ${detection.subRepos.length} git repositories:`);
|
|
1218
|
+
console.log(` ${detection.subRepos.join(", ")}\n`);
|
|
1219
|
+
|
|
1220
|
+
// ── Config repo selection ────────────────────────────────────
|
|
1221
|
+
let configRepoName;
|
|
1222
|
+
if (isPreset || dryRun) {
|
|
1223
|
+
// Non-interactive: pick first repo alphabetically as default
|
|
1224
|
+
configRepoName = detection.subRepos[0];
|
|
1225
|
+
console.log(` ${INFO} Using ${c.cyan}${configRepoName}${c.reset} as config repo (first alphabetically).\n`);
|
|
1226
|
+
} else {
|
|
1227
|
+
// Interactive: prompt user to choose config repo
|
|
1228
|
+
console.log(` Which repo should hold Taskplane config?`);
|
|
1229
|
+
for (let i = 0; i < detection.subRepos.length; i++) {
|
|
1230
|
+
console.log(` ${c.dim}${i + 1}.${c.reset} ${detection.subRepos[i]}`);
|
|
1231
|
+
}
|
|
1232
|
+
console.log();
|
|
1233
|
+
const configRepoAnswer = await ask(
|
|
1234
|
+
"Config repo (name or number)",
|
|
1235
|
+
detection.subRepos[0]
|
|
1236
|
+
);
|
|
1237
|
+
// Accept numeric index or repo name
|
|
1238
|
+
const asNum = parseInt(configRepoAnswer, 10);
|
|
1239
|
+
if (asNum >= 1 && asNum <= detection.subRepos.length) {
|
|
1240
|
+
configRepoName = detection.subRepos[asNum - 1];
|
|
1241
|
+
} else if (detection.subRepos.includes(configRepoAnswer)) {
|
|
1242
|
+
configRepoName = configRepoAnswer;
|
|
1243
|
+
} else {
|
|
1244
|
+
die(`Unknown repo: ${configRepoAnswer}. Must be one of: ${detection.subRepos.join(", ")}`);
|
|
1245
|
+
}
|
|
1246
|
+
console.log(` Using config repo: ${c.cyan}${configRepoName}${c.reset}\n`);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const configRepoRoot = path.join(projectRoot, configRepoName);
|
|
1250
|
+
const taskplaneDir = path.join(configRepoRoot, ".taskplane");
|
|
1251
|
+
|
|
1252
|
+
// ── Existing config overwrite check for workspace reinit ─────
|
|
1253
|
+
let userConfirmedOverwrite = false;
|
|
1254
|
+
if (fs.existsSync(taskplaneDir) && !force) {
|
|
1255
|
+
console.log(`${WARN} Taskplane config already exists in ${configRepoName}/.taskplane/.`);
|
|
1256
|
+
const proceed = await confirm(" Overwrite existing files?", false);
|
|
1257
|
+
if (!proceed) {
|
|
1258
|
+
console.log(" Aborted.");
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
userConfirmedOverwrite = true;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ── Gather config values (workspace mode) ───────────────────
|
|
1265
|
+
let vars;
|
|
1266
|
+
if (preset === "minimal" || preset === "full" || preset === "runner-only") {
|
|
1267
|
+
vars = getPresetVars(preset, projectRoot, tasksRootOverride);
|
|
1268
|
+
console.log(` Using preset: ${c.cyan}${preset}${c.reset}`);
|
|
1269
|
+
if (tasksRootOverride) {
|
|
1270
|
+
console.log(` Task directory: ${c.cyan}${tasksRootOverride}${c.reset}`);
|
|
1271
|
+
}
|
|
1272
|
+
console.log();
|
|
1273
|
+
} else {
|
|
1274
|
+
vars = await getInteractiveVars(projectRoot, tasksRootOverride);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// ── tmux / spawn mode detection ─────────────────────────────
|
|
1278
|
+
const { spawnMode, hasTmux } = detectSpawnMode();
|
|
1279
|
+
vars.spawn_mode = spawnMode;
|
|
1280
|
+
|
|
1281
|
+
if (preset !== "runner-only" && !hasTmux) {
|
|
1282
|
+
console.log(` ${WARN} tmux not found. Using subprocess mode.`);
|
|
1283
|
+
console.log(` Run ${c.cyan}taskplane install-tmux${c.reset} for full orchestrator support.\n`);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
|
|
1287
|
+
|
|
1288
|
+
// ── Dry-run: show what would be created ─────────────────────
|
|
1289
|
+
if (dryRun) {
|
|
1290
|
+
console.log(`\n${c.bold}Dry run — files that would be created:${c.reset}\n`);
|
|
1291
|
+
printWorkspaceFileList(vars, noExamples, preset, exampleTemplateDirs, configRepoName, configRepoRoot);
|
|
1292
|
+
console.log(` ${c.green}create${c.reset} .pi/taskplane-pointer.json`);
|
|
1293
|
+
console.log(` ${c.green}create${c.reset} .pi/taskplane-workspace.yaml`);
|
|
1294
|
+
console.log();
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ── Scaffold .taskplane/ in config repo ─────────────────────
|
|
1299
|
+
console.log(`\n${c.bold}Creating files in ${configRepoName}/.taskplane/...${c.reset}\n`);
|
|
1300
|
+
// Skip existing files only when --force was NOT used AND the user did NOT confirm overwrite
|
|
1301
|
+
const skipIfExists = !force && !userConfirmedOverwrite;
|
|
1302
|
+
|
|
1303
|
+
// Agent prompts
|
|
1304
|
+
for (const agent of ["task-worker.md", "task-reviewer.md", "task-merger.md"]) {
|
|
1305
|
+
copyTemplate(
|
|
1306
|
+
path.join(TEMPLATES_DIR, "agents", "local", agent),
|
|
1307
|
+
path.join(taskplaneDir, "agents", agent),
|
|
1308
|
+
{ skipIfExists, label: `${configRepoName}/.taskplane/agents/${agent}` }
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Task runner config
|
|
1313
|
+
writeFile(
|
|
1314
|
+
path.join(taskplaneDir, "task-runner.yaml"),
|
|
1315
|
+
generateTaskRunnerYaml(vars),
|
|
1316
|
+
{ skipIfExists, label: `${configRepoName}/.taskplane/task-runner.yaml` }
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
// Orchestrator config (skip for runner-only preset)
|
|
1320
|
+
if (preset !== "runner-only") {
|
|
1321
|
+
writeFile(
|
|
1322
|
+
path.join(taskplaneDir, "task-orchestrator.yaml"),
|
|
1323
|
+
generateOrchestratorYaml(vars),
|
|
1324
|
+
{ skipIfExists, label: `${configRepoName}/.taskplane/task-orchestrator.yaml` }
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Project config JSON (taskplane-config.json)
|
|
1329
|
+
const projectConfig = generateProjectConfig(vars);
|
|
1330
|
+
writeFile(
|
|
1331
|
+
path.join(taskplaneDir, "taskplane-config.json"),
|
|
1332
|
+
JSON.stringify(projectConfig, null, 2) + "\n",
|
|
1333
|
+
{ skipIfExists, label: `${configRepoName}/.taskplane/taskplane-config.json` }
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
// Version tracker (always overwrite)
|
|
1337
|
+
const versionInfo = {
|
|
1338
|
+
version: getPackageVersion(),
|
|
1339
|
+
installedAt: new Date().toISOString(),
|
|
1340
|
+
lastUpgraded: new Date().toISOString(),
|
|
1341
|
+
components: { agents: getPackageVersion(), config: getPackageVersion() },
|
|
1342
|
+
};
|
|
1343
|
+
writeFile(
|
|
1344
|
+
path.join(taskplaneDir, "taskplane.json"),
|
|
1345
|
+
JSON.stringify(versionInfo, null, 2) + "\n",
|
|
1346
|
+
{ label: `${configRepoName}/.taskplane/taskplane.json` }
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
// Workspace definition (workspace.json)
|
|
1350
|
+
const workspaceConfig = {
|
|
1351
|
+
repos: detection.subRepos.map(name => ({
|
|
1352
|
+
name,
|
|
1353
|
+
path: `../${name}`,
|
|
1354
|
+
default_branch: "main",
|
|
1355
|
+
})),
|
|
1356
|
+
routing: {
|
|
1357
|
+
tasks_root: vars.tasks_root,
|
|
1358
|
+
default_repo: configRepoName,
|
|
1359
|
+
strict: false,
|
|
1360
|
+
},
|
|
1361
|
+
};
|
|
1362
|
+
writeFile(
|
|
1363
|
+
path.join(taskplaneDir, "workspace.json"),
|
|
1364
|
+
JSON.stringify(workspaceConfig, null, 2) + "\n",
|
|
1365
|
+
{ skipIfExists, label: `${configRepoName}/.taskplane/workspace.json` }
|
|
1366
|
+
);
|
|
1367
|
+
|
|
1368
|
+
// CONTEXT.md — tasks area context
|
|
1369
|
+
const tasksDir = path.join(configRepoRoot, vars.tasks_root);
|
|
1370
|
+
const contextSrc = fs.readFileSync(path.join(TEMPLATES_DIR, "tasks", "CONTEXT.md"), "utf-8");
|
|
1371
|
+
writeFile(
|
|
1372
|
+
path.join(tasksDir, "CONTEXT.md"),
|
|
1373
|
+
interpolate(contextSrc, vars),
|
|
1374
|
+
{ skipIfExists, label: `${configRepoName}/${vars.tasks_root}/CONTEXT.md` }
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
// Example tasks
|
|
1378
|
+
if (!noExamples) {
|
|
1379
|
+
for (const exampleName of exampleTemplateDirs) {
|
|
1380
|
+
const exampleDir = path.join(TEMPLATES_DIR, "tasks", exampleName);
|
|
1381
|
+
const destDir = path.join(tasksDir, exampleName);
|
|
1382
|
+
for (const file of ["PROMPT.md", "STATUS.md"]) {
|
|
1383
|
+
const srcPath = path.join(exampleDir, file);
|
|
1384
|
+
if (!fs.existsSync(srcPath)) continue;
|
|
1385
|
+
const src = fs.readFileSync(srcPath, "utf-8");
|
|
1386
|
+
writeFile(path.join(destDir, file), interpolate(src, vars), {
|
|
1387
|
+
skipIfExists,
|
|
1388
|
+
label: `${configRepoName}/${vars.tasks_root}/${exampleName}/${file}`,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
if (exampleTemplateDirs.length === 0) {
|
|
1393
|
+
console.log(` ${WARN} No example task templates found under templates/tasks/EXAMPLE-*`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// ── Gitignore enforcement in config repo ────────────────────
|
|
1398
|
+
// Use .taskplane/ prefix so patterns apply within the config repo's
|
|
1399
|
+
// .taskplane/ directory (e.g., ".taskplane/.pi/batch-state.json")
|
|
1400
|
+
// Per spec: standard .pi/ patterns + .worktrees/ in config repo root
|
|
1401
|
+
const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { dryRun: false, prefix: ".taskplane/" });
|
|
1402
|
+
|
|
1403
|
+
if (gitignoreResult.created) {
|
|
1404
|
+
console.log(` ${c.green}create${c.reset} ${configRepoName}/.gitignore`);
|
|
1405
|
+
} else if (gitignoreResult.added.length > 0) {
|
|
1406
|
+
console.log(` ${c.green}update${c.reset} ${configRepoName}/.gitignore (${gitignoreResult.added.length} entries added)`);
|
|
1407
|
+
} else {
|
|
1408
|
+
console.log(` ${c.dim}skip${c.reset} ${configRepoName}/.gitignore (all entries already present)`);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Check for tracked runtime artifacts in config repo (workspace-scoped)
|
|
1412
|
+
const wsIsInteractive = !isPreset && !dryRun;
|
|
1413
|
+
await detectAndOfferUntrackArtifacts(configRepoRoot, { dryRun: false, interactive: wsIsInteractive, prefix: ".taskplane/" });
|
|
1414
|
+
|
|
1415
|
+
// ── Pointer file in workspace root .pi/ ─────────────────────
|
|
1416
|
+
const pointer = {
|
|
1417
|
+
config_repo: configRepoName,
|
|
1418
|
+
config_path: ".taskplane",
|
|
1419
|
+
};
|
|
1420
|
+
writeFile(
|
|
1421
|
+
path.join(projectRoot, ".pi", "taskplane-pointer.json"),
|
|
1422
|
+
JSON.stringify(pointer, null, 2) + "\n",
|
|
1423
|
+
{ label: ".pi/taskplane-pointer.json" }
|
|
1424
|
+
);
|
|
1425
|
+
writeFile(
|
|
1426
|
+
path.join(projectRoot, ".pi", "taskplane-workspace.yaml"),
|
|
1427
|
+
generateWorkspaceYaml(detection.subRepos, configRepoName, vars.tasks_root),
|
|
1428
|
+
{ label: ".pi/taskplane-workspace.yaml" },
|
|
1429
|
+
);
|
|
1430
|
+
|
|
1431
|
+
// ── Auto-commit config files in the config repo ─────────────
|
|
1432
|
+
await autoCommitTaskFiles(configRepoRoot, vars.tasks_root);
|
|
1433
|
+
// Also stage and commit .taskplane/ directory and .gitignore
|
|
1434
|
+
try {
|
|
1435
|
+
execSync('git add .taskplane/ .gitignore', { cwd: configRepoRoot, stdio: "pipe" });
|
|
1436
|
+
const status = execSync("git diff --cached --name-only", { cwd: configRepoRoot, stdio: "pipe" })
|
|
1437
|
+
.toString().trim();
|
|
1438
|
+
if (status) {
|
|
1439
|
+
execSync('git commit -m "chore: initialize taskplane workspace config"', {
|
|
1440
|
+
cwd: configRepoRoot,
|
|
1441
|
+
stdio: "pipe",
|
|
1442
|
+
});
|
|
1443
|
+
console.log(`\n ${c.green}git${c.reset} committed .taskplane/ and .gitignore to ${configRepoName}`);
|
|
1444
|
+
}
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
console.log(`\n ${WARN} Could not auto-commit .taskplane/ to ${configRepoName}.`);
|
|
1447
|
+
console.log(` ${c.dim}Run manually: cd ${configRepoName} && git add .taskplane/ .gitignore && git commit -m "add taskplane config"${c.reset}`);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// ── Post-init guidance ──────────────────────────────────────
|
|
1451
|
+
console.log(`\n${OK} ${c.bold}Taskplane initialized in workspace mode!${c.reset}\n`);
|
|
1452
|
+
console.log(` Config repo: ${c.cyan}${configRepoName}/.taskplane/${c.reset}`);
|
|
1453
|
+
console.log(` Pointer: ${c.cyan}.pi/taskplane-pointer.json${c.reset}`);
|
|
1454
|
+
console.log(` Workspace: ${c.cyan}.pi/taskplane-workspace.yaml${c.reset}\n`);
|
|
1455
|
+
console.log(` ${WARN} ${c.bold}Important:${c.reset} merge these changes to your default branch (e.g., ${c.cyan}develop${c.reset})`);
|
|
1456
|
+
console.log(` before other team members run ${c.cyan}taskplane init${c.reset}.\n`);
|
|
1457
|
+
console.log(` cd ${configRepoName}`);
|
|
1458
|
+
console.log(` git push && ${c.dim}[create PR / merge to default branch]${c.reset}\n`);
|
|
1459
|
+
console.log(`${c.bold}Quick start:${c.reset}`);
|
|
1460
|
+
console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`);
|
|
1461
|
+
if (preset !== "runner-only") {
|
|
1462
|
+
console.log(` ${c.cyan}/orch-plan all${c.reset} # preview waves/lanes/dependencies`);
|
|
1463
|
+
console.log(` ${c.cyan}/orch all${c.reset} # run via orchestrator`);
|
|
1464
|
+
}
|
|
1465
|
+
console.log();
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// ── Existing config overwrite check (for repo mode force reinit) ──
|
|
1470
|
+
let repoUserConfirmedOverwrite = false;
|
|
610
1471
|
const hasConfig =
|
|
611
1472
|
fs.existsSync(path.join(projectRoot, ".pi", "task-runner.yaml")) ||
|
|
612
|
-
fs.existsSync(path.join(projectRoot, ".pi", "task-orchestrator.yaml"))
|
|
1473
|
+
fs.existsSync(path.join(projectRoot, ".pi", "task-orchestrator.yaml")) ||
|
|
1474
|
+
fs.existsSync(path.join(projectRoot, ".pi", "taskplane-config.json"));
|
|
613
1475
|
|
|
614
|
-
if (hasConfig && !force) {
|
|
1476
|
+
if (hasConfig && !force && resolvedMode === "repo") {
|
|
615
1477
|
console.log(`${WARN} Taskplane config already exists in this project.`);
|
|
616
1478
|
const proceed = await confirm(" Overwrite existing files?", false);
|
|
617
1479
|
if (!proceed) {
|
|
618
1480
|
console.log(" Aborted.");
|
|
619
1481
|
return;
|
|
620
1482
|
}
|
|
1483
|
+
repoUserConfirmedOverwrite = true;
|
|
621
1484
|
}
|
|
622
1485
|
|
|
623
1486
|
// Gather config values
|
|
@@ -633,22 +1496,37 @@ async function cmdInit(args) {
|
|
|
633
1496
|
vars = await getInteractiveVars(projectRoot, tasksRootOverride);
|
|
634
1497
|
}
|
|
635
1498
|
|
|
1499
|
+
// ── tmux / spawn mode detection ──────────────────────────────
|
|
1500
|
+
// Detect tmux availability and set spawn_mode for orchestrator config.
|
|
1501
|
+
// Runs for all init modes (repo and workspace) per spec.
|
|
1502
|
+
// Silent when tmux is found; shows guidance when missing.
|
|
1503
|
+
// Skipped for runner-only preset (no orchestrator config generated).
|
|
1504
|
+
const { spawnMode, hasTmux } = detectSpawnMode();
|
|
1505
|
+
vars.spawn_mode = spawnMode;
|
|
1506
|
+
|
|
1507
|
+
if (preset !== "runner-only" && !hasTmux) {
|
|
1508
|
+
console.log(` ${WARN} tmux not found. Using subprocess mode.`);
|
|
1509
|
+
console.log(` Run ${c.cyan}taskplane install-tmux${c.reset} for full orchestrator support.\n`);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
636
1512
|
const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
|
|
637
1513
|
|
|
638
1514
|
if (dryRun) {
|
|
639
1515
|
console.log(`\n${c.bold}Dry run — files that would be created:${c.reset}\n`);
|
|
640
|
-
printFileList(vars, noExamples, preset, exampleTemplateDirs);
|
|
1516
|
+
printFileList(vars, noExamples, preset, exampleTemplateDirs, projectRoot);
|
|
641
1517
|
return;
|
|
642
1518
|
}
|
|
643
1519
|
|
|
644
1520
|
// Scaffold files
|
|
645
1521
|
console.log(`\n${c.bold}Creating files...${c.reset}\n`);
|
|
646
|
-
|
|
1522
|
+
// Skip existing files only when --force was NOT used AND the user did NOT confirm overwrite
|
|
1523
|
+
const skipIfExists = !force && !repoUserConfirmedOverwrite;
|
|
647
1524
|
|
|
648
|
-
// Agent prompts
|
|
1525
|
+
// Agent prompts — copy thin local files (base prompts ship in the package
|
|
1526
|
+
// and are composed automatically by the task-runner at runtime)
|
|
649
1527
|
for (const agent of ["task-worker.md", "task-reviewer.md", "task-merger.md"]) {
|
|
650
1528
|
copyTemplate(
|
|
651
|
-
path.join(TEMPLATES_DIR, "agents", agent),
|
|
1529
|
+
path.join(TEMPLATES_DIR, "agents", "local", agent),
|
|
652
1530
|
path.join(projectRoot, ".pi", "agents", agent),
|
|
653
1531
|
{ skipIfExists, label: `.pi/agents/${agent}` }
|
|
654
1532
|
);
|
|
@@ -670,6 +1548,13 @@ async function cmdInit(args) {
|
|
|
670
1548
|
);
|
|
671
1549
|
}
|
|
672
1550
|
|
|
1551
|
+
// Unified project config JSON
|
|
1552
|
+
writeFile(
|
|
1553
|
+
path.join(projectRoot, ".pi", "taskplane-config.json"),
|
|
1554
|
+
JSON.stringify(generateProjectConfig(vars), null, 2) + "\n",
|
|
1555
|
+
{ skipIfExists, label: ".pi/taskplane-config.json" },
|
|
1556
|
+
);
|
|
1557
|
+
|
|
673
1558
|
// Version tracker (always overwrite)
|
|
674
1559
|
const versionInfo = {
|
|
675
1560
|
version: getPackageVersion(),
|
|
@@ -711,6 +1596,26 @@ async function cmdInit(args) {
|
|
|
711
1596
|
}
|
|
712
1597
|
}
|
|
713
1598
|
|
|
1599
|
+
// ── Gitignore enforcement ────────────────────────────────────────────
|
|
1600
|
+
// Must run BEFORE autoCommitTaskFiles() so that:
|
|
1601
|
+
// 1. .gitignore changes are committed alongside task files
|
|
1602
|
+
// 2. git rm --cached removals don't get bundled into the task auto-commit
|
|
1603
|
+
const isInteractive = !isPreset && !dryRun;
|
|
1604
|
+
const gitignoreResult = ensureGitignoreEntries(projectRoot, { dryRun });
|
|
1605
|
+
|
|
1606
|
+
if (!dryRun) {
|
|
1607
|
+
if (gitignoreResult.created) {
|
|
1608
|
+
console.log(` ${c.green}create${c.reset} .gitignore`);
|
|
1609
|
+
} else if (gitignoreResult.added.length > 0) {
|
|
1610
|
+
console.log(` ${c.green}update${c.reset} .gitignore (${gitignoreResult.added.length} entries added)`);
|
|
1611
|
+
} else {
|
|
1612
|
+
console.log(` ${c.dim}skip${c.reset} .gitignore (all entries already present)`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// Check for tracked runtime artifacts and offer to untrack
|
|
1617
|
+
await detectAndOfferUntrackArtifacts(projectRoot, { dryRun, interactive: isInteractive });
|
|
1618
|
+
|
|
714
1619
|
// Auto-commit task files to git so they're available in worktrees
|
|
715
1620
|
await autoCommitTaskFiles(projectRoot, vars.tasks_root);
|
|
716
1621
|
|
|
@@ -775,7 +1680,7 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
|
|
|
775
1680
|
};
|
|
776
1681
|
}
|
|
777
1682
|
|
|
778
|
-
function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
|
|
1683
|
+
function printFileList(vars, noExamples, preset, exampleTemplateDirs = [], projectRoot = null) {
|
|
779
1684
|
const files = [
|
|
780
1685
|
".pi/agents/task-worker.md",
|
|
781
1686
|
".pi/agents/task-reviewer.md",
|
|
@@ -783,6 +1688,7 @@ function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
|
|
|
783
1688
|
".pi/task-runner.yaml",
|
|
784
1689
|
];
|
|
785
1690
|
if (preset !== "runner-only") files.push(".pi/task-orchestrator.yaml");
|
|
1691
|
+
files.push(".pi/taskplane-config.json");
|
|
786
1692
|
files.push(".pi/taskplane.json");
|
|
787
1693
|
files.push(`${vars.tasks_root}/CONTEXT.md`);
|
|
788
1694
|
if (!noExamples) {
|
|
@@ -792,9 +1698,56 @@ function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
|
|
|
792
1698
|
}
|
|
793
1699
|
}
|
|
794
1700
|
for (const f of files) console.log(` ${c.green}create${c.reset} ${f}`);
|
|
1701
|
+
|
|
1702
|
+
// Show gitignore entries that would be added
|
|
1703
|
+
if (projectRoot) {
|
|
1704
|
+
const gitignoreResult = ensureGitignoreEntries(projectRoot, { dryRun: true });
|
|
1705
|
+
if (gitignoreResult.added.length > 0) {
|
|
1706
|
+
const action = fs.existsSync(path.join(projectRoot, ".gitignore")) ? "update" : "create";
|
|
1707
|
+
console.log(` ${c.green}${action}${c.reset} .gitignore (${gitignoreResult.added.length} entries)`);
|
|
1708
|
+
} else {
|
|
1709
|
+
console.log(` ${c.dim}skip${c.reset} .gitignore (all entries already present)`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
795
1713
|
console.log();
|
|
796
1714
|
}
|
|
797
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* Print the list of files that would be created for workspace mode (dry-run).
|
|
1718
|
+
* Similar to printFileList but paths are scoped to <configRepo>/.taskplane/.
|
|
1719
|
+
*/
|
|
1720
|
+
function printWorkspaceFileList(vars, noExamples, preset, exampleTemplateDirs, configRepoName, configRepoRoot) {
|
|
1721
|
+
const prefix = `${configRepoName}/.taskplane`;
|
|
1722
|
+
const files = [
|
|
1723
|
+
`${prefix}/agents/task-worker.md`,
|
|
1724
|
+
`${prefix}/agents/task-reviewer.md`,
|
|
1725
|
+
`${prefix}/agents/task-merger.md`,
|
|
1726
|
+
`${prefix}/task-runner.yaml`,
|
|
1727
|
+
];
|
|
1728
|
+
if (preset !== "runner-only") files.push(`${prefix}/task-orchestrator.yaml`);
|
|
1729
|
+
files.push(`${prefix}/taskplane-config.json`);
|
|
1730
|
+
files.push(`${prefix}/taskplane.json`);
|
|
1731
|
+
files.push(`${prefix}/workspace.json`);
|
|
1732
|
+
files.push(`${configRepoName}/${vars.tasks_root}/CONTEXT.md`);
|
|
1733
|
+
if (!noExamples) {
|
|
1734
|
+
for (const exampleName of exampleTemplateDirs) {
|
|
1735
|
+
files.push(`${configRepoName}/${vars.tasks_root}/${exampleName}/PROMPT.md`);
|
|
1736
|
+
files.push(`${configRepoName}/${vars.tasks_root}/${exampleName}/STATUS.md`);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
for (const f of files) console.log(` ${c.green}create${c.reset} ${f}`);
|
|
1740
|
+
|
|
1741
|
+
// Show gitignore entries that would be added to config repo (workspace-scoped)
|
|
1742
|
+
const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { dryRun: true, prefix: ".taskplane/" });
|
|
1743
|
+
if (gitignoreResult.added.length > 0) {
|
|
1744
|
+
const action = fs.existsSync(path.join(configRepoRoot, ".gitignore")) ? "update" : "create";
|
|
1745
|
+
console.log(` ${c.green}${action}${c.reset} ${configRepoName}/.gitignore (${gitignoreResult.added.length} entries)`);
|
|
1746
|
+
} else {
|
|
1747
|
+
console.log(` ${c.dim}skip${c.reset} ${configRepoName}/.gitignore (all entries already present)`);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
798
1751
|
// ─── Workspace Mode Detection (for doctor) ─────────────────────────────────
|
|
799
1752
|
|
|
800
1753
|
/**
|
|
@@ -1251,6 +2204,44 @@ async function cmdInstallTmux(args) {
|
|
|
1251
2204
|
|
|
1252
2205
|
// ─── doctor ─────────────────────────────────────────────────────────────────
|
|
1253
2206
|
|
|
2207
|
+
function resolveDoctorConfigLocation(projectRoot, isWorkspaceMode) {
|
|
2208
|
+
if (!isWorkspaceMode) {
|
|
2209
|
+
return {
|
|
2210
|
+
root: projectRoot,
|
|
2211
|
+
prefix: ".pi",
|
|
2212
|
+
label: ".pi",
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
const pointerPath = path.join(projectRoot, ".pi", "taskplane-pointer.json");
|
|
2217
|
+
if (!fs.existsSync(pointerPath)) {
|
|
2218
|
+
return {
|
|
2219
|
+
root: projectRoot,
|
|
2220
|
+
prefix: ".pi",
|
|
2221
|
+
label: ".pi",
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
try {
|
|
2226
|
+
const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf-8"));
|
|
2227
|
+
if (pointer.config_repo && pointer.config_path) {
|
|
2228
|
+
return {
|
|
2229
|
+
root: path.resolve(projectRoot, pointer.config_repo),
|
|
2230
|
+
prefix: pointer.config_path,
|
|
2231
|
+
label: `${pointer.config_repo}/${pointer.config_path}`,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
} catch {
|
|
2235
|
+
// fall through to workspace-root .pi fallback
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
return {
|
|
2239
|
+
root: projectRoot,
|
|
2240
|
+
prefix: ".pi",
|
|
2241
|
+
label: ".pi",
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
|
|
1254
2245
|
function cmdDoctor() {
|
|
1255
2246
|
const projectRoot = process.cwd();
|
|
1256
2247
|
let issues = 0;
|
|
@@ -1278,7 +2269,12 @@ function cmdDoctor() {
|
|
|
1278
2269
|
if (!ok) issues++;
|
|
1279
2270
|
}
|
|
1280
2271
|
|
|
1281
|
-
//
|
|
2272
|
+
// Detect workspace mode early so config-path checks can resolve via pointer
|
|
2273
|
+
const wsResult = loadWorkspaceConfigForDoctor(projectRoot);
|
|
2274
|
+
const isWorkspaceMode = wsResult.mode === "workspace";
|
|
2275
|
+
const configLocation = resolveDoctorConfigLocation(projectRoot, isWorkspaceMode);
|
|
2276
|
+
|
|
2277
|
+
// Check tmux and spawn_mode compatibility
|
|
1282
2278
|
const hasTmux = commandExists("tmux");
|
|
1283
2279
|
console.log(
|
|
1284
2280
|
` ${hasTmux ? OK : `${WARN}`} tmux installed${hasTmux ? ` ${c.dim}(${getVersion("tmux", "-V")})${c.reset}` : ` ${c.dim}(optional — needed for spawn_mode: tmux)${c.reset}`}`
|
|
@@ -1287,6 +2283,37 @@ function cmdDoctor() {
|
|
|
1287
2283
|
console.log(` ${c.dim}→ Run ${c.cyan}taskplane install-tmux${c.dim} to install${c.reset}`);
|
|
1288
2284
|
}
|
|
1289
2285
|
|
|
2286
|
+
// Check if project config requires tmux but it's not installed
|
|
2287
|
+
const orchConfigPath = path.join(configLocation.root, configLocation.prefix, "task-orchestrator.yaml");
|
|
2288
|
+
const orchJsonPath = path.join(configLocation.root, configLocation.prefix, "taskplane-config.json");
|
|
2289
|
+
let projectSpawnMode = null;
|
|
2290
|
+
try {
|
|
2291
|
+
if (fs.existsSync(orchJsonPath)) {
|
|
2292
|
+
const json = JSON.parse(fs.readFileSync(orchJsonPath, "utf-8"));
|
|
2293
|
+
projectSpawnMode =
|
|
2294
|
+
json?.orchestrator?.orchestrator?.spawnMode ||
|
|
2295
|
+
json?.orchestrator?.spawnMode ||
|
|
2296
|
+
json?.orchestrator?.spawn_mode ||
|
|
2297
|
+
null;
|
|
2298
|
+
} else if (fs.existsSync(orchConfigPath)) {
|
|
2299
|
+
const raw = fs.readFileSync(orchConfigPath, "utf-8");
|
|
2300
|
+
const match = raw.match(/spawn_mode:\s*["']?(\w+)["']?/);
|
|
2301
|
+
if (match) projectSpawnMode = match[1];
|
|
2302
|
+
}
|
|
2303
|
+
} catch { /* best effort */ }
|
|
2304
|
+
|
|
2305
|
+
if (projectSpawnMode === "tmux" && !hasTmux) {
|
|
2306
|
+
issues++;
|
|
2307
|
+
console.log(
|
|
2308
|
+
` ${FAIL} spawn_mode is ${c.bold}"tmux"${c.reset} but tmux is not installed`
|
|
2309
|
+
);
|
|
2310
|
+
if (process.platform === "win32") {
|
|
2311
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane install-tmux${c.dim} to install tmux${c.reset}`);
|
|
2312
|
+
} else {
|
|
2313
|
+
console.log(` ${c.dim}→ Install tmux: ${c.cyan}brew install tmux${c.dim} (macOS) or ${c.cyan}sudo apt install tmux${c.dim} (Linux)${c.reset}`);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
|
|
1290
2317
|
// Check package installation
|
|
1291
2318
|
const pkgJson = path.join(PACKAGE_ROOT, "package.json");
|
|
1292
2319
|
const pkgVersion = getPackageVersion();
|
|
@@ -1294,10 +2321,6 @@ function cmdDoctor() {
|
|
|
1294
2321
|
const installType = isProjectLocal ? "project-local" : "global";
|
|
1295
2322
|
console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`);
|
|
1296
2323
|
|
|
1297
|
-
// Detect workspace mode
|
|
1298
|
-
const wsResult = loadWorkspaceConfigForDoctor(projectRoot);
|
|
1299
|
-
const isWorkspaceMode = wsResult.mode === "workspace";
|
|
1300
|
-
|
|
1301
2324
|
if (isWorkspaceMode) {
|
|
1302
2325
|
console.log();
|
|
1303
2326
|
if (wsResult.error) {
|
|
@@ -1320,6 +2343,138 @@ function cmdDoctor() {
|
|
|
1320
2343
|
}
|
|
1321
2344
|
}
|
|
1322
2345
|
|
|
2346
|
+
// ── Workspace pointer chain validation ──────────────────────────────
|
|
2347
|
+
// Validates: pointer file → config repo → .taskplane/ directory → default branch
|
|
2348
|
+
if (isWorkspaceMode && wsResult.config) {
|
|
2349
|
+
console.log();
|
|
2350
|
+
const pointerPath = path.join(projectRoot, ".pi", "taskplane-pointer.json");
|
|
2351
|
+
|
|
2352
|
+
// Check 1: Pointer file exists and is valid JSON with required fields
|
|
2353
|
+
let pointer = null;
|
|
2354
|
+
if (!fs.existsSync(pointerPath)) {
|
|
2355
|
+
console.log(` ${FAIL} .pi/taskplane-pointer.json missing [POINTER_MISSING]`);
|
|
2356
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to create the workspace pointer${c.reset}`);
|
|
2357
|
+
issues++;
|
|
2358
|
+
} else {
|
|
2359
|
+
try {
|
|
2360
|
+
pointer = JSON.parse(fs.readFileSync(pointerPath, "utf-8"));
|
|
2361
|
+
if (!pointer.config_repo || !pointer.config_path) {
|
|
2362
|
+
console.log(` ${FAIL} .pi/taskplane-pointer.json missing required fields (config_repo, config_path) [POINTER_SCHEMA_INVALID]`);
|
|
2363
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to recreate the pointer${c.reset}`);
|
|
2364
|
+
pointer = null;
|
|
2365
|
+
issues++;
|
|
2366
|
+
} else {
|
|
2367
|
+
console.log(` ${OK} .pi/taskplane-pointer.json ${c.dim}(→ ${pointer.config_repo}/${pointer.config_path})${c.reset}`);
|
|
2368
|
+
}
|
|
2369
|
+
} catch {
|
|
2370
|
+
console.log(` ${FAIL} .pi/taskplane-pointer.json is not valid JSON [POINTER_PARSE_ERROR]`);
|
|
2371
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to recreate the pointer${c.reset}`);
|
|
2372
|
+
issues++;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// Check 2: Config repo path exists on disk
|
|
2377
|
+
let configRepoRoot = null;
|
|
2378
|
+
if (pointer) {
|
|
2379
|
+
configRepoRoot = path.resolve(projectRoot, pointer.config_repo);
|
|
2380
|
+
if (!fs.existsSync(configRepoRoot)) {
|
|
2381
|
+
console.log(` ${FAIL} config repo not found: ${pointer.config_repo} [CONFIG_REPO_NOT_FOUND]`);
|
|
2382
|
+
console.log(` ${c.dim}→ Clone ${pointer.config_repo} into ${projectRoot}${c.reset}`);
|
|
2383
|
+
configRepoRoot = null;
|
|
2384
|
+
issues++;
|
|
2385
|
+
} else if (!isInsideGitRepo(configRepoRoot)) {
|
|
2386
|
+
console.log(` ${FAIL} config repo is not a git repository: ${pointer.config_repo} [CONFIG_REPO_NOT_GIT]`);
|
|
2387
|
+
console.log(` ${c.dim}→ Run: git init ${configRepoRoot}${c.reset}`);
|
|
2388
|
+
configRepoRoot = null;
|
|
2389
|
+
issues++;
|
|
2390
|
+
} else {
|
|
2391
|
+
console.log(` ${OK} config repo: ${pointer.config_repo} ${c.dim}(${configRepoRoot})${c.reset}`);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Check 3: .taskplane/ directory exists in config repo
|
|
2396
|
+
let taskplaneDirExists = false;
|
|
2397
|
+
if (configRepoRoot) {
|
|
2398
|
+
const taskplaneDir = path.join(configRepoRoot, pointer.config_path);
|
|
2399
|
+
if (!fs.existsSync(taskplaneDir)) {
|
|
2400
|
+
console.log(` ${FAIL} ${pointer.config_repo}/${pointer.config_path}/ not found [CONFIG_DIR_NOT_FOUND]`);
|
|
2401
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to create the config directory${c.reset}`);
|
|
2402
|
+
issues++;
|
|
2403
|
+
} else {
|
|
2404
|
+
console.log(` ${OK} ${pointer.config_repo}/${pointer.config_path}/ exists`);
|
|
2405
|
+
taskplaneDirExists = true;
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// Check 4: .taskplane/ exists on config repo's default branch (not just current branch)
|
|
2410
|
+
if (configRepoRoot && taskplaneDirExists) {
|
|
2411
|
+
try {
|
|
2412
|
+
// Get current branch name
|
|
2413
|
+
const currentBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
2414
|
+
cwd: configRepoRoot,
|
|
2415
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2416
|
+
timeout: 5000,
|
|
2417
|
+
}).toString().trim();
|
|
2418
|
+
|
|
2419
|
+
// Detect default branch (try origin/HEAD, fall back to main/master heuristic)
|
|
2420
|
+
let defaultBranch = null;
|
|
2421
|
+
try {
|
|
2422
|
+
const originHead = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
2423
|
+
cwd: configRepoRoot,
|
|
2424
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2425
|
+
timeout: 5000,
|
|
2426
|
+
}).toString().trim();
|
|
2427
|
+
// refs/remotes/origin/main → main
|
|
2428
|
+
defaultBranch = originHead.replace(/^refs\/remotes\/origin\//, "");
|
|
2429
|
+
} catch {
|
|
2430
|
+
// origin/HEAD not set — try common default branch names
|
|
2431
|
+
for (const candidate of ["main", "master", "develop"]) {
|
|
2432
|
+
try {
|
|
2433
|
+
execFileSync("git", ["rev-parse", "--verify", `refs/heads/${candidate}`], {
|
|
2434
|
+
cwd: configRepoRoot,
|
|
2435
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2436
|
+
timeout: 5000,
|
|
2437
|
+
});
|
|
2438
|
+
defaultBranch = candidate;
|
|
2439
|
+
break;
|
|
2440
|
+
} catch {
|
|
2441
|
+
// candidate doesn't exist, try next
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
if (defaultBranch && currentBranch !== defaultBranch) {
|
|
2447
|
+
// Check if .taskplane/ exists on the default branch via git ls-tree
|
|
2448
|
+
try {
|
|
2449
|
+
const lsOutput = execFileSync("git", ["ls-tree", "--name-only", defaultBranch, pointer.config_path + "/"], {
|
|
2450
|
+
cwd: configRepoRoot,
|
|
2451
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2452
|
+
timeout: 5000,
|
|
2453
|
+
}).toString().trim();
|
|
2454
|
+
|
|
2455
|
+
if (lsOutput) {
|
|
2456
|
+
console.log(` ${OK} ${pointer.config_path}/ exists on default branch (${defaultBranch})`);
|
|
2457
|
+
} else {
|
|
2458
|
+
console.log(` ${WARN} ${pointer.config_path}/ exists on current branch (${currentBranch}) but not on default branch (${defaultBranch})`);
|
|
2459
|
+
console.log(` ${c.dim}→ Merge to ${defaultBranch} so teammates can onboard${c.reset}`);
|
|
2460
|
+
}
|
|
2461
|
+
} catch {
|
|
2462
|
+
// ls-tree failed — directory doesn't exist on that branch
|
|
2463
|
+
console.log(` ${WARN} ${pointer.config_path}/ exists on current branch (${currentBranch}) but not on default branch (${defaultBranch})`);
|
|
2464
|
+
console.log(` ${c.dim}→ Merge to ${defaultBranch} so teammates can onboard${c.reset}`);
|
|
2465
|
+
}
|
|
2466
|
+
} else if (defaultBranch && currentBranch === defaultBranch) {
|
|
2467
|
+
console.log(` ${OK} ${pointer.config_path}/ on default branch (${defaultBranch})`);
|
|
2468
|
+
} else {
|
|
2469
|
+
// Could not determine default branch — skip this check silently
|
|
2470
|
+
console.log(` ${INFO} could not determine default branch for ${pointer.config_repo} — skipping branch check`);
|
|
2471
|
+
}
|
|
2472
|
+
} catch {
|
|
2473
|
+
// git commands failed — skip branch check
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
1323
2478
|
// Step 1: Validate repo topology (workspace mode + valid config only)
|
|
1324
2479
|
if (isWorkspaceMode && wsResult.config) {
|
|
1325
2480
|
console.log();
|
|
@@ -1355,39 +2510,67 @@ function cmdDoctor() {
|
|
|
1355
2510
|
|
|
1356
2511
|
// Check project config (common — both modes)
|
|
1357
2512
|
console.log();
|
|
2513
|
+
const hasUnifiedJson = fs.existsSync(path.join(configLocation.root, configLocation.prefix, "taskplane-config.json"));
|
|
1358
2514
|
const configFiles = [
|
|
1359
|
-
{ path: "
|
|
1360
|
-
{ path: "
|
|
1361
|
-
{ path: "
|
|
1362
|
-
{ path: "
|
|
1363
|
-
{ path: "
|
|
1364
|
-
{ path: "
|
|
2515
|
+
{ path: "taskplane-config.json", required: false },
|
|
2516
|
+
{ path: "task-runner.yaml", required: !hasUnifiedJson },
|
|
2517
|
+
{ path: "task-orchestrator.yaml", required: !hasUnifiedJson },
|
|
2518
|
+
{ path: "agents/task-worker.md", required: true },
|
|
2519
|
+
{ path: "agents/task-reviewer.md", required: true },
|
|
2520
|
+
{ path: "agents/task-merger.md", required: true },
|
|
2521
|
+
{ path: "taskplane.json", required: false },
|
|
1365
2522
|
];
|
|
1366
2523
|
|
|
1367
|
-
// In workspace mode, include workspace config in the config files check
|
|
1368
|
-
if (isWorkspaceMode && !wsResult.error) {
|
|
1369
|
-
configFiles.push({ path: ".pi/taskplane-workspace.yaml", required: true });
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
2524
|
let missingRequiredConfigs = 0;
|
|
1373
2525
|
for (const { path: relPath, required } of configFiles) {
|
|
1374
|
-
const
|
|
2526
|
+
const fullPath = path.join(configLocation.root, configLocation.prefix, relPath);
|
|
2527
|
+
const displayPath = `${configLocation.label}/${relPath}`;
|
|
2528
|
+
const exists = fs.existsSync(fullPath);
|
|
1375
2529
|
if (exists) {
|
|
1376
|
-
console.log(` ${OK} ${
|
|
2530
|
+
console.log(` ${OK} ${displayPath} exists`);
|
|
1377
2531
|
} else if (required) {
|
|
1378
|
-
console.log(` ${FAIL} ${
|
|
2532
|
+
console.log(` ${FAIL} ${displayPath} missing`);
|
|
1379
2533
|
missingRequiredConfigs++;
|
|
1380
2534
|
issues++;
|
|
1381
2535
|
} else {
|
|
1382
|
-
console.log(` ${WARN} ${
|
|
2536
|
+
console.log(` ${WARN} ${displayPath} missing ${c.dim}(optional)${c.reset}`);
|
|
1383
2537
|
}
|
|
1384
2538
|
}
|
|
2539
|
+
|
|
2540
|
+
if (isWorkspaceMode && !wsResult.error) {
|
|
2541
|
+
const wsConfigPath = path.join(projectRoot, ".pi", "taskplane-workspace.yaml");
|
|
2542
|
+
if (fs.existsSync(wsConfigPath)) {
|
|
2543
|
+
console.log(` ${OK} .pi/taskplane-workspace.yaml exists`);
|
|
2544
|
+
} else {
|
|
2545
|
+
console.log(` ${FAIL} .pi/taskplane-workspace.yaml missing`);
|
|
2546
|
+
missingRequiredConfigs++;
|
|
2547
|
+
issues++;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
1385
2551
|
if (missingRequiredConfigs > 0) {
|
|
1386
2552
|
console.log(` ${c.dim}→ Run: taskplane init${c.reset}`);
|
|
1387
2553
|
}
|
|
1388
2554
|
|
|
2555
|
+
// ── Legacy YAML config migration warning ────────────────────────────
|
|
2556
|
+
// Detect YAML config files without a JSON equivalent (taskplane-config.json).
|
|
2557
|
+
{
|
|
2558
|
+
const yamlRunnerPath = path.join(configLocation.root, configLocation.prefix, "task-runner.yaml");
|
|
2559
|
+
const yamlOrchestratorPath = path.join(configLocation.root, configLocation.prefix, "task-orchestrator.yaml");
|
|
2560
|
+
const jsonConfigPath = path.join(configLocation.root, configLocation.prefix, "taskplane-config.json");
|
|
2561
|
+
|
|
2562
|
+
const hasYamlRunner = fs.existsSync(yamlRunnerPath);
|
|
2563
|
+
const hasYamlOrchestrator = fs.existsSync(yamlOrchestratorPath);
|
|
2564
|
+
const hasJsonConfig = fs.existsSync(jsonConfigPath);
|
|
2565
|
+
|
|
2566
|
+
if ((hasYamlRunner || hasYamlOrchestrator) && !hasJsonConfig) {
|
|
2567
|
+
console.log(` ${WARN} legacy YAML config detected in ${configLocation.label}`);
|
|
2568
|
+
console.log(` ${c.dim}→ Run /settings to migrate to taskplane-config.json${c.reset}`);
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
|
|
1389
2572
|
// Check task areas from config
|
|
1390
|
-
const { paths: taskAreaPaths, contexts: taskAreaContexts, areaRepoIds } = discoverTaskAreaMetadata(projectRoot);
|
|
2573
|
+
const { paths: taskAreaPaths, contexts: taskAreaContexts, areaRepoIds } = discoverTaskAreaMetadata(projectRoot, configLocation.root, configLocation.prefix);
|
|
1391
2574
|
if (taskAreaPaths.length > 0) {
|
|
1392
2575
|
console.log();
|
|
1393
2576
|
for (const areaPath of taskAreaPaths) {
|
|
@@ -1420,12 +2603,151 @@ function cmdDoctor() {
|
|
|
1420
2603
|
console.log(` ${OK} area '${areaName}' repo_id: ${repoId}`);
|
|
1421
2604
|
} else {
|
|
1422
2605
|
console.log(` ${FAIL} area '${areaName}' repo_id '${repoId}' does not match any workspace repo [AREA_REPO_ID_UNKNOWN]`);
|
|
1423
|
-
console.log(` ${c.dim}→ Available repos: ${knownRepoIds.join(", ")}. Fix repo_id in .
|
|
2606
|
+
console.log(` ${c.dim}→ Available repos: ${knownRepoIds.join(", ")}. Fix repo_id in ${configLocation.label}/task-runner.yaml${c.reset}`);
|
|
1424
2607
|
issues++;
|
|
1425
2608
|
}
|
|
1426
2609
|
}
|
|
1427
2610
|
}
|
|
1428
2611
|
|
|
2612
|
+
// ── Gitignore and tracked artifact checks ───────────────────────────
|
|
2613
|
+
// In workspace mode, check the config repo's .gitignore (with .taskplane/ prefix).
|
|
2614
|
+
// In repo mode, check the project root's .gitignore directly.
|
|
2615
|
+
// Workspace root is NOT a git repo, so gitignore checks don't apply there.
|
|
2616
|
+
|
|
2617
|
+
if (isWorkspaceMode && wsResult.config) {
|
|
2618
|
+
// Workspace mode: find config repo from pointer file
|
|
2619
|
+
const pointerPath = path.join(projectRoot, ".pi", "taskplane-pointer.json");
|
|
2620
|
+
let configRepoRoot = null;
|
|
2621
|
+
let configRepoName = null;
|
|
2622
|
+
try {
|
|
2623
|
+
const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf-8"));
|
|
2624
|
+
if (pointer.config_repo) {
|
|
2625
|
+
configRepoName = pointer.config_repo;
|
|
2626
|
+
configRepoRoot = path.resolve(projectRoot, pointer.config_repo);
|
|
2627
|
+
}
|
|
2628
|
+
} catch {
|
|
2629
|
+
// Pointer missing or invalid — skip gitignore checks (pointer validation is Step 2)
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
if (configRepoRoot && isInsideGitRepo(configRepoRoot)) {
|
|
2633
|
+
const prefix = ".taskplane/";
|
|
2634
|
+
console.log();
|
|
2635
|
+
|
|
2636
|
+
// Check 1: Gitignore entries present in config repo
|
|
2637
|
+
const gitignorePath = path.join(configRepoRoot, ".gitignore");
|
|
2638
|
+
const gitignoreExists = fs.existsSync(gitignorePath);
|
|
2639
|
+
if (!gitignoreExists) {
|
|
2640
|
+
console.log(` ${WARN} ${configRepoName}/.gitignore missing — Taskplane runtime entries not protected`);
|
|
2641
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`);
|
|
2642
|
+
// WARN doesn't increment issues (it's advisory, not a failure)
|
|
2643
|
+
} else {
|
|
2644
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
2645
|
+
const existingLines = new Set(content.split(/\r?\n/).map(l => l.trim()));
|
|
2646
|
+
const allEntries = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES];
|
|
2647
|
+
const missing = allEntries
|
|
2648
|
+
.map(entry => `${prefix}${entry}`)
|
|
2649
|
+
.filter(prefixed => !existingLines.has(prefixed));
|
|
2650
|
+
|
|
2651
|
+
if (missing.length === 0) {
|
|
2652
|
+
console.log(` ${OK} ${configRepoName}/.gitignore has all Taskplane runtime entries`);
|
|
2653
|
+
} else {
|
|
2654
|
+
console.log(` ${WARN} ${configRepoName}/.gitignore missing ${missing.length} Taskplane runtime entr${missing.length === 1 ? "y" : "ies"}`);
|
|
2655
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`);
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// Check 2: Tracked artifact detection in config repo
|
|
2660
|
+
const scanDirs = [`${prefix}.pi/`, `${prefix}.worktrees/`];
|
|
2661
|
+
try {
|
|
2662
|
+
const raw = execFileSync("git", ["ls-files", "--", ...scanDirs], {
|
|
2663
|
+
cwd: configRepoRoot,
|
|
2664
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2665
|
+
timeout: 10000,
|
|
2666
|
+
}).toString().trim();
|
|
2667
|
+
const trackedFiles = raw ? raw.split(/\r?\n/) : [];
|
|
2668
|
+
|
|
2669
|
+
if (trackedFiles.length > 0) {
|
|
2670
|
+
const prefixedPatterns = ALL_GITIGNORE_PATTERNS.map(p => `${prefix}${p}`);
|
|
2671
|
+
const patterns = prefixedPatterns.map(p => patternToRegex(p));
|
|
2672
|
+
const matchedFiles = trackedFiles.filter(file =>
|
|
2673
|
+
patterns.some(regex => regex.test(file))
|
|
2674
|
+
);
|
|
2675
|
+
|
|
2676
|
+
if (matchedFiles.length > 0) {
|
|
2677
|
+
console.log(` ${FAIL} ${matchedFiles.length} runtime artifact${matchedFiles.length === 1 ? "" : "s"} tracked by git in ${configRepoName}`);
|
|
2678
|
+
for (const file of matchedFiles) {
|
|
2679
|
+
console.log(` ${c.dim}${file}${c.reset}`);
|
|
2680
|
+
}
|
|
2681
|
+
console.log(` ${c.dim}→ Run: cd ${configRepoName} && git rm --cached ${matchedFiles.join(" ")}${c.reset}`);
|
|
2682
|
+
issues++;
|
|
2683
|
+
} else {
|
|
2684
|
+
console.log(` ${OK} no runtime artifacts tracked by git in ${configRepoName}`);
|
|
2685
|
+
}
|
|
2686
|
+
} else {
|
|
2687
|
+
console.log(` ${OK} no runtime artifacts tracked by git in ${configRepoName}`);
|
|
2688
|
+
}
|
|
2689
|
+
} catch {
|
|
2690
|
+
// git ls-files failed — skip silently (repo validation already covers git issues)
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
} else if (!isWorkspaceMode && isInsideGitRepo(projectRoot)) {
|
|
2694
|
+
// Repo mode: check project root .gitignore and tracked artifacts
|
|
2695
|
+
console.log();
|
|
2696
|
+
|
|
2697
|
+
// Check 1: Gitignore entries present
|
|
2698
|
+
const gitignorePath = path.join(projectRoot, ".gitignore");
|
|
2699
|
+
const gitignoreExists = fs.existsSync(gitignorePath);
|
|
2700
|
+
if (!gitignoreExists) {
|
|
2701
|
+
console.log(` ${WARN} .gitignore missing — Taskplane runtime entries not protected`);
|
|
2702
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`);
|
|
2703
|
+
} else {
|
|
2704
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
2705
|
+
const existingLines = new Set(content.split(/\r?\n/).map(l => l.trim()));
|
|
2706
|
+
const allEntries = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES];
|
|
2707
|
+
const missing = allEntries.filter(entry => !existingLines.has(entry));
|
|
2708
|
+
|
|
2709
|
+
if (missing.length === 0) {
|
|
2710
|
+
console.log(` ${OK} .gitignore has all Taskplane runtime entries`);
|
|
2711
|
+
} else {
|
|
2712
|
+
console.log(` ${WARN} .gitignore missing ${missing.length} Taskplane runtime entr${missing.length === 1 ? "y" : "ies"}`);
|
|
2713
|
+
console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// Check 2: Tracked artifact detection
|
|
2718
|
+
const scanDirs = [".pi/", ".worktrees/"];
|
|
2719
|
+
try {
|
|
2720
|
+
const raw = execFileSync("git", ["ls-files", "--", ...scanDirs], {
|
|
2721
|
+
cwd: projectRoot,
|
|
2722
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2723
|
+
timeout: 10000,
|
|
2724
|
+
}).toString().trim();
|
|
2725
|
+
const trackedFiles = raw ? raw.split(/\r?\n/) : [];
|
|
2726
|
+
|
|
2727
|
+
if (trackedFiles.length > 0) {
|
|
2728
|
+
const patterns = ALL_GITIGNORE_PATTERNS.map(p => patternToRegex(p));
|
|
2729
|
+
const matchedFiles = trackedFiles.filter(file =>
|
|
2730
|
+
patterns.some(regex => regex.test(file))
|
|
2731
|
+
);
|
|
2732
|
+
|
|
2733
|
+
if (matchedFiles.length > 0) {
|
|
2734
|
+
console.log(` ${FAIL} ${matchedFiles.length} runtime artifact${matchedFiles.length === 1 ? "" : "s"} tracked by git`);
|
|
2735
|
+
for (const file of matchedFiles) {
|
|
2736
|
+
console.log(` ${c.dim}${file}${c.reset}`);
|
|
2737
|
+
}
|
|
2738
|
+
console.log(` ${c.dim}→ Run: git rm --cached ${matchedFiles.join(" ")}${c.reset}`);
|
|
2739
|
+
issues++;
|
|
2740
|
+
} else {
|
|
2741
|
+
console.log(` ${OK} no runtime artifacts tracked by git`);
|
|
2742
|
+
}
|
|
2743
|
+
} else {
|
|
2744
|
+
console.log(` ${OK} no runtime artifacts tracked by git`);
|
|
2745
|
+
}
|
|
2746
|
+
} catch {
|
|
2747
|
+
// git ls-files failed — skip silently
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
|
|
1429
2751
|
console.log();
|
|
1430
2752
|
if (issues === 0) {
|
|
1431
2753
|
console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);
|