Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9c0e7e5
feat: add workspace-aware polyrepo orchestration
loopyd Apr 21, 2026
d644942
fix: refine workspace sync remediation
loopyd Apr 21, 2026
77781bb
fix: decouple workspace sync blocking helpers
loopyd Apr 21, 2026
6a88bcf
fix: persist orch plan tui output
loopyd Apr 21, 2026
766fe3f
Add orch-plan live widget state
loopyd Apr 21, 2026
81bfcce
feat: box merge status and refine orch-plan output
loopyd Apr 22, 2026
cb5bda7
fix: bump yaml to 2.8.3
loopyd Apr 22, 2026
bb85330
Refine orch-plan widgets and refresh task prompts
loopyd Apr 22, 2026
5326f21
feat: harden polyrepo routing and resume recovery
loopyd Apr 22, 2026
47ed2a0
Harden polyrepo resume and submodule safety
loopyd Apr 22, 2026
16ce915
feat: add lane-runner submodule diagnostics
loopyd Apr 22, 2026
cc24ef7
fix(taskplane): filter gitlink-only dirty state in submodule safety c…
loopyd Apr 22, 2026
bd5e6b9
fix(taskplane): fetch submodule remotes before gitlink reachability c…
loopyd Apr 22, 2026
3601c22
chore: reset all taskplane tasks to fresh pending state
loopyd Apr 22, 2026
5907468
Revert "chore: reset all taskplane tasks to fresh pending state"
loopyd Apr 22, 2026
70e00ec
fix(taskplane): robust submodule gitlink reachability check
loopyd Apr 22, 2026
ffea264
fix(taskplane): double-layer artifact filter — segment matching + .gi…
loopyd Apr 22, 2026
917fe72
fix(tasklane): allow clean exit for already-complete tasks
loopyd Apr 22, 2026
0fbc3da
fix(taskplane): remove corrupted lines from isCommitReachableOnRemote
loopyd Apr 22, 2026
6b476b8
fix(batch-reset): add state-aware resume logic to prevent redundant o…
loopyd Apr 23, 2026
d5996a7
feat(taskplane): batch-reset workflow improvements
loopyd Apr 23, 2026
9c5f46d
fix(git): improve submodule gitlink validation in merge worktrees
loopyd Apr 23, 2026
826a347
fix(git): improve submodule gitlink validation by using ls-remote mor…
loopyd Apr 23, 2026
1ee2309
fix(git): filter Python build artifacts in isArtifactStatusLine
loopyd Apr 23, 2026
4ff7ca4
fix(git): improve gitignore resolution for submodules
loopyd Apr 23, 2026
f8b4115
fix(runtime): improve loop diagnostics and bugfix workflow
loopyd Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ Thumbs.db
*~
.vscode/
.idea/
tmp/
270 changes: 270 additions & 0 deletions bin/taskplane.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2560,6 +2560,177 @@ function resolveDoctorConfigLocation(projectRoot, isWorkspaceMode) {
};
}

const DOCTOR_WORKSPACE_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;

function normalizeDoctorPath(value) {
try {
return fs.realpathSync.native(path.resolve(value)).replace(/\\/g, "/").toLowerCase();
} catch {
return path.resolve(value).replace(/\\/g, "/").toLowerCase();
}
}

function runGitForDoctor(args, cwd) {
try {
const stdout = execFileSync("git", args, {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
}).trim();
return { ok: true, stdout };
} catch (error) {
const stderr = error && typeof error === "object" && error.stderr
? String(error.stderr).trim()
: "";
return { ok: false, stdout: "", stderr };
}
}

function uniqueSorted(values) {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
}

function listConfiguredSubmodulePathsForDoctor(repoRoot) {
const result = runGitForDoctor(["config", "-f", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], repoRoot);
if (!result.ok || !result.stdout) return [];

const paths = [];
for (const line of result.stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const value = trimmed.replace(/^submodule\.[^.]+\.path\s+/, "").trim();
if (value) paths.push(value);
}
return uniqueSorted(paths);
}

function listGitlinkPathsForDoctor(repoRoot) {
const result = runGitForDoctor(["ls-files", "--stage"], repoRoot);
if (!result.ok || !result.stdout) return [];

const paths = [];
for (const line of result.stdout.split(/\r?\n/)) {
const match = line.match(/^160000\s+[0-9a-f]+\s+\d+\t(.+)$/i);
if (match?.[1]) paths.push(match[1]);
}
return uniqueSorted(paths);
}

function parseSubmoduleStatusLineForDoctor(line) {
if (!line) return null;
const prefix = line[0];
const trimmed = line.slice(1).trim();
if (!trimmed) return null;
const firstSpace = trimmed.indexOf(" ");
if (firstSpace <= 0) return null;

let pathAndDescription = trimmed.slice(firstSpace + 1).trim();
const descriptionMatch = pathAndDescription.match(/^(.*)\s+\((.*)\)$/);
if (descriptionMatch) {
pathAndDescription = descriptionMatch[1].trim();
}
if (!pathAndDescription) return null;

return {
path: pathAndDescription,
state:
prefix === "-" ? "uninitialized" :
prefix === "+" ? "drifted" :
prefix === "U" ? "conflict" :
"ok",
};
}

function listSubmoduleStatusForDoctor(repoRoot) {
const result = runGitForDoctor(["submodule", "status", "--recursive"], repoRoot);
if (!result.ok || !result.stdout) return [];
return result.stdout
.split(/\r?\n/)
.map(parseSubmoduleStatusLineForDoctor)
.filter(Boolean)
.sort((left, right) => left.path.localeCompare(right.path));
}

function applyDoctorSubmodulePolicy(target, rawOrchestrator) {
if (!rawOrchestrator || typeof rawOrchestrator !== "object" || Array.isArray(rawOrchestrator)) return;
const rawFailure = rawOrchestrator.failure;
const rawCore = rawOrchestrator.orchestrator;
if (rawFailure?.submoduleFailureMode === "permissive" || rawFailure?.submoduleFailureMode === "strict") {
target.failureMode = rawFailure.submoduleFailureMode;
}
if (["manual", "init-only", "recursive-on-drift"].includes(rawFailure?.onSubmoduleDrift)) {
target.onSubmoduleDrift = rawFailure.onSubmoduleDrift;
}
if (rawCore?.submoduleRepoIdStrategy === "path-basename") {
target.repoIdStrategy = rawCore.submoduleRepoIdStrategy;
}
}

function loadDoctorSubmodulePolicy(configLocation) {
const policy = {
failureMode: "permissive",
onSubmoduleDrift: "manual",
repoIdStrategy: "path-basename",
};

try {
const { raw } = readGlobalPreferencesForCli();
applyDoctorSubmodulePolicy(policy, raw?.orchestrator);
} catch {
// ignore preferences parse failures here; doctor remains read-only best effort
}

const configPath = path.join(configLocation.root, configLocation.prefix, "taskplane-config.json");
if (!fs.existsSync(configPath)) return policy;

try {
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
applyDoctorSubmodulePolicy(policy, raw?.orchestrator);
} catch {
// malformed project config is surfaced elsewhere; keep default policy here
}

return policy;
}

function doctorPlannerSyncCommand() {
return "/orch-plan <areas|all> --sync";
}

function buildDoctorUninitializedHint(policy, repoRoot, submodulePath) {
const syncCmd = doctorPlannerSyncCommand();
const initCmd = `git -C "${repoRoot}" submodule update --init -- "${submodulePath}"`;
const recursiveCmd = `git -C "${repoRoot}" submodule update --init --recursive -- "${submodulePath}"`;
if (policy.onSubmoduleDrift === "manual") {
return `On Submodule Drift is manual. Switch it to init-only or recursive-on-drift, then run ${syncCmd}, or run ${initCmd}.`;
}
if (policy.onSubmoduleDrift === "init-only") {
return `Run ${syncCmd} to initialize it, or run ${initCmd}.`;
}
return `Run ${syncCmd} to initialize it recursively, or run ${recursiveCmd}.`;
}

function buildDoctorDriftHint(policy, repoRoot, submodulePath) {
const syncCmd = doctorPlannerSyncCommand();
const updateCmd = `git -C "${repoRoot}" submodule update --init --recursive -- "${submodulePath}"`;
if (policy.onSubmoduleDrift === "manual") {
return `On Submodule Drift is manual. Switch it to recursive-on-drift, then run ${syncCmd}, or run ${updateCmd}.`;
}
if (policy.onSubmoduleDrift === "init-only") {
return `On Submodule Drift is init-only, which does not repair drift. Switch it to recursive-on-drift and rerun ${syncCmd}, or run ${updateCmd}.`;
}
return `Run ${syncCmd} to realign the checkout, or run ${updateCmd}.`;
}

function reportDoctorSubmoduleFinding(strictMode, message, hint) {
console.log(` ${strictMode ? FAIL : WARN} ${message}`);
if (hint) {
console.log(` ${c.dim}→ ${hint}${c.reset}`);
}
return strictMode ? 1 : 0;
}

function cmdDoctor() {
const projectRoot = process.cwd();
let issues = 0;
Expand Down Expand Up @@ -2786,6 +2957,105 @@ function cmdDoctor() {
}
}

// ── Submodule policy + advisory checks (read-only) ─────────────────
console.log();
const submodulePolicy = loadDoctorSubmodulePolicy(configLocation);
const strictSubmoduleMode = submodulePolicy.failureMode === "strict";
console.log(
` ${INFO} submodule policy ${c.dim}(failure: ${submodulePolicy.failureMode}, on drift: ${submodulePolicy.onSubmoduleDrift}, repo IDs: ${submodulePolicy.repoIdStrategy})${c.reset}`,
);

const workspaceRepoPaths = new Map();
const repoRootsForSubmoduleScan = [];
if (wsResult.config) {
for (const repoId of [...wsResult.config.repos.keys()].sort()) {
const repo = wsResult.config.repos.get(repoId);
const resolvedPath = path.resolve(projectRoot, repo.path);
repoRootsForSubmoduleScan.push({ label: repoId, root: resolvedPath });
workspaceRepoPaths.set(normalizeDoctorPath(resolvedPath), repoId);
if (!DOCTOR_WORKSPACE_REPO_ID_PATTERN.test(repoId)) {
issues += reportDoctorSubmoduleFinding(
strictSubmoduleMode,
`workspace repo ID '${repoId}' does not match the lowercase letters/digits/hyphen policy`,
"Rename the repo ID before relying on workspace routing or future submodule imports.",
);
}
}
} else if (isInsideGitRepo(projectRoot)) {
repoRootsForSubmoduleScan.push({ label: path.basename(projectRoot), root: projectRoot });
}

let trackedSubmodules = 0;
const collisionCandidates = new Map();
for (const { label, root } of repoRootsForSubmoduleScan) {
const configuredPaths = listConfiguredSubmodulePathsForDoctor(root);
const gitlinkPaths = listGitlinkPathsForDoctor(root);
const statuses = listSubmoduleStatusForDoctor(root);
const statusByPath = new Map(statuses.map((entry) => [entry.path, entry]));
const allPaths = [...new Set([...configuredPaths, ...gitlinkPaths, ...statuses.map((entry) => entry.path)])]
.sort((left, right) => left.localeCompare(right));

trackedSubmodules += allPaths.length;

for (const submodulePath of allPaths) {
const absolutePath = path.resolve(root, submodulePath);
const mappedRepoId = workspaceRepoPaths.get(normalizeDoctorPath(absolutePath));

if (wsResult.config && !mappedRepoId && submodulePolicy.repoIdStrategy === "path-basename") {
const derivedRepoId = path.basename(submodulePath).trim().toLowerCase();
if (!DOCTOR_WORKSPACE_REPO_ID_PATTERN.test(derivedRepoId)) {
issues += reportDoctorSubmoduleFinding(
strictSubmoduleMode,
`${label}: submodule '${submodulePath}' is not declared in workspace.repos and basename import would derive invalid repo ID '${derivedRepoId}'`,
"Rename the submodule path or add an explicit workspace.repos entry with a valid repo ID, then rerun /orch-plan <areas|all> --sync.",
);
} else {
const candidates = collisionCandidates.get(derivedRepoId) ?? [];
candidates.push(`${label}:${submodulePath}`);
collisionCandidates.set(derivedRepoId, candidates);
issues += reportDoctorSubmoduleFinding(
strictSubmoduleMode,
`${label}: submodule '${submodulePath}' is not declared in workspace.repos`,
`Run ${doctorPlannerSyncCommand()} to import it, or add a workspace.repos entry for '${submodulePath}' (repo ID '${derivedRepoId}' under path-basename strategy).`,
);
}
}

const status = statusByPath.get(submodulePath);
if (status?.state === "uninitialized" || (!fs.existsSync(absolutePath) && (configuredPaths.includes(submodulePath) || gitlinkPaths.includes(submodulePath)))) {
issues += reportDoctorSubmoduleFinding(
strictSubmoduleMode,
`${label}: submodule '${submodulePath}' is not initialized`,
buildDoctorUninitializedHint(submodulePolicy, root, submodulePath),
);
continue;
}

if (status?.state === "drifted" || status?.state === "conflict") {
issues += reportDoctorSubmoduleFinding(
strictSubmoduleMode,
`${label}: submodule '${submodulePath}' is ${status.state === "conflict" ? "in conflict" : "drifted from the recorded gitlink commit"}`,
buildDoctorDriftHint(submodulePolicy, root, submodulePath),
);
}
}
}

for (const [derivedRepoId, candidates] of collisionCandidates) {
if (candidates.length < 2) continue;
issues += reportDoctorSubmoduleFinding(
strictSubmoduleMode,
`multiple undeclared submodules would map to repo ID '${derivedRepoId}'`,
`Add explicit workspace.repos entries for ${candidates.join(", ")} instead of relying on path-basename imports, then rerun ${doctorPlannerSyncCommand()}.`,
);
}

if (trackedSubmodules === 0) {
console.log(` ${OK} no submodules detected`);
} else if (issues === 0 || !strictSubmoduleMode) {
console.log(` ${OK} submodule scan complete ${c.dim}(${trackedSubmodules} tracked)${c.reset}`);
}

// Check project config (common — both modes)
console.log();
const hasUnifiedJson = fs.existsSync(path.join(configLocation.root, configLocation.prefix, "taskplane-config.json"));
Expand Down
37 changes: 35 additions & 2 deletions dashboard/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ const $ = (id) => document.getElementById(id);

const $batchId = $("batch-id");
const $batchPhase = $("batch-phase");
const $workspaceMode = $("workspace-mode");
const $submoduleStatus = $("submodule-status");
const $connDot = $("conn-dot");
const $lastUpdate = $("last-update");
const $progressBarBg = $("progress-bar-bg");
Expand Down Expand Up @@ -247,9 +249,8 @@ let lastBatchId = null; // TP-178: track batchId for stale viewer detection (#4

/**
* Build a sorted, deduplicated list of repo IDs from the batch payload.
* Returns empty array when mode !== "workspace" or when fewer than 2 repos.
*/
function buildRepoSet(batch) {
function collectRepoIds(batch) {
if (!batch || batch.mode !== "workspace") return [];

const repos = new Set();
Expand All @@ -266,6 +267,15 @@ function buildRepoSet(batch) {
}
}
const sorted = Array.from(repos).sort();
return sorted;
}

/**
* Build the repo set used by the filter dropdown.
* Returns empty array when mode !== "workspace" or when fewer than 2 repos.
*/
function buildRepoSet(batch) {
const sorted = collectRepoIds(batch);
return sorted.length >= 2 ? sorted : [];
}

Expand Down Expand Up @@ -424,11 +434,34 @@ function renderHeader(batch) {
$batchId.textContent = "—";
$batchPhase.textContent = "No batch";
$batchPhase.className = "header-badge badge-phase";
$workspaceMode.style.display = "none";
$submoduleStatus.style.display = "none";
return;
}
$batchId.textContent = batch.batchId;
$batchPhase.textContent = batch.phase;
$batchPhase.className = `header-badge badge-phase phase-${batch.phase}`;

if (batch.mode === "workspace") {
const repoIds = collectRepoIds(batch);
const repoCount = repoIds.length;
$workspaceMode.style.display = "";
$workspaceMode.textContent = `${repoCount} repo${repoCount === 1 ? "" : "s"}`;
$workspaceMode.title = repoCount > 0
? `Workspace batch spanning ${repoCount} repo${repoCount === 1 ? "" : "s"}`
: "Workspace batch";
} else {
$workspaceMode.style.display = "none";
}

if (batch.workspaceSyncStatus) {
$submoduleStatus.style.display = "";
$submoduleStatus.textContent = batch.workspaceSyncStatus.label;
$submoduleStatus.title = batch.workspaceSyncStatus.detail || "Workspace repo/submodule sync status";
$submoduleStatus.className = `header-badge badge-sync sync-${batch.workspaceSyncStatus.state}`;
} else {
$submoduleStatus.style.display = "none";
}
}

// ─── Render: Summary ────────────────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions dashboard/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
<img src="taskplane-word-white.svg" alt="Taskplane" class="header-logo" id="header-logo">
<span class="header-badge badge-batch" id="batch-id">—</span>
<span class="header-badge badge-phase" id="batch-phase">—</span>
<span class="header-badge badge-workspace" id="workspace-mode" style="display:none;">—</span>
<span class="header-badge badge-sync" id="submodule-status" style="display:none;">—</span>
<select class="history-select" id="history-select" title="View past batch runs">
<option value="">History ▾</option>
</select>
Expand Down
20 changes: 20 additions & 0 deletions dashboard/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,26 @@ body {
color: var(--accent);
}

.badge-workspace {
background: var(--accent-tint-bg);
border: 1px solid var(--border);
color: var(--accent);
}

.badge-sync {
border: 1px solid var(--border);
}

.sync-clean {
background: var(--green-tint-bg);
color: var(--green);
}

.sync-none {
background: var(--badge-pending-bg);
color: var(--text-muted);
}

.badge-phase {
font-weight: 600;
text-transform: uppercase;
Expand Down
Loading