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/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: "subprocess"
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(projectRoot, ".pi", "task-runner.yaml");
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
- // Check for existing config
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
- const skipIfExists = !force;
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
- // Check tmux (optional)
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: ".pi/task-runner.yaml", required: true },
1360
- { path: ".pi/task-orchestrator.yaml", required: true },
1361
- { path: ".pi/agents/task-worker.md", required: true },
1362
- { path: ".pi/agents/task-reviewer.md", required: true },
1363
- { path: ".pi/agents/task-merger.md", required: true },
1364
- { path: ".pi/taskplane.json", required: false },
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 exists = fs.existsSync(path.join(projectRoot, relPath));
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} ${relPath} exists`);
2530
+ console.log(` ${OK} ${displayPath} exists`);
1377
2531
  } else if (required) {
1378
- console.log(` ${FAIL} ${relPath} missing`);
2532
+ console.log(` ${FAIL} ${displayPath} missing`);
1379
2533
  missingRequiredConfigs++;
1380
2534
  issues++;
1381
2535
  } else {
1382
- console.log(` ${WARN} ${relPath} missing ${c.dim}(optional)${c.reset}`);
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 .pi/task-runner.yaml${c.reset}`);
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`);