taskplane 0.26.1 → 0.28.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 CHANGED
@@ -71,14 +71,17 @@ pi install -l npm:taskplane
71
71
  ## Quickstart
72
72
 
73
73
  ### 1. Initialize a project (to scaffold settings)
74
+ This step assumes you have installed Taskplane using one of the above options.
75
+
74
76
  (NOTE: if 'my-project' is a monorepo, be sure to run git init first. Taskplane uses git worktrees to isolate agent coding until you're ready to merge back to your default branch.)
77
+
75
78
  ```bash
76
79
  cd my-project
77
80
  taskplane init
78
81
  ```
79
82
  You'll answer a few questions. You can usually just accept the defaults.
80
83
 
81
- This creates config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a single repo or a multi-repo workspace. See the [install tutorial](docs/tutorials/install.md) for workspace mode and other scenarios.
84
+ This creates: config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a monorepo or a polyrepo workspace. See the [install tutorial](docs/tutorials/install.md) for workspace mode and other scenarios.
82
85
 
83
86
  Already have a task folder (for example `docs/task-management`)? Use:
84
87
 
package/bin/taskplane.mjs CHANGED
@@ -787,18 +787,24 @@ export async function collectInitAgentConfig({
787
787
  return initAgentConfig;
788
788
  }
789
789
 
790
+ /** Normalize a path string to forward slashes (for config file output). */
791
+ function fwdSlash(p) {
792
+ return typeof p === "string" ? p.replace(/\\/g, "/") : p;
793
+ }
794
+
790
795
  export function generateProjectConfig(vars, _initAgentConfig = null) {
796
+ const tasksRoot = fwdSlash(vars.tasks_root);
791
797
  const projectConfig = {
792
798
  configVersion: 1,
793
799
  taskRunner: {
794
800
  project: { name: vars.project_name, description: "" },
795
- paths: { tasks: vars.tasks_root },
801
+ paths: { tasks: tasksRoot },
796
802
  testing: { commands: buildTestingCommands(vars) },
797
803
  taskAreas: {
798
804
  [vars.default_area]: {
799
- path: vars.tasks_root,
805
+ path: tasksRoot,
800
806
  prefix: vars.default_prefix,
801
- context: `${vars.tasks_root}/CONTEXT.md`,
807
+ context: `${tasksRoot}/CONTEXT.md`,
802
808
  },
803
809
  },
804
810
  },
@@ -820,10 +826,11 @@ export function generateProjectConfig(vars, _initAgentConfig = null) {
820
826
  }
821
827
 
822
828
  function generateWorkspaceYaml(repoNames, defaultRepo, tasksRoot) {
829
+ const normalizedTasksRoot = fwdSlash(tasksRoot);
823
830
  const reposBlock = repoNames
824
831
  .map((name) => ` ${name}:\n path: "${name}"`)
825
832
  .join("\n");
826
- return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${tasksRoot}"\n default_repo: "${defaultRepo}"\n task_packet_repo: "${defaultRepo}"\n`;
833
+ return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${normalizedTasksRoot}"\n default_repo: "${defaultRepo}"\n task_packet_repo: "${defaultRepo}"\n`;
827
834
  }
828
835
 
829
836
  function readWorkspaceJson(configRepoRoot) {
@@ -1923,7 +1930,7 @@ async function cmdInit(args) {
1923
1930
  default_branch: "main",
1924
1931
  })),
1925
1932
  routing: {
1926
- tasks_root: vars.tasks_root,
1933
+ tasks_root: fwdSlash(vars.tasks_root),
1927
1934
  default_repo: configRepoName,
1928
1935
  strict: false,
1929
1936
  },
@@ -699,21 +699,30 @@ function renderLanesTasks(batch, sessions) {
699
699
  const showPacketHome = !!packetHomeRepo && packetHomeRepo !== (tRepo || lane.repoId || "");
700
700
 
701
701
  // Progress cell
702
+ // TP-174: Prefer V2 snapshot progress (segment-scoped when available)
703
+ // over full STATUS.md counts when the task is actively running on this lane.
704
+ // TP-176: Succeeded tasks always show 100% regardless of sidecar/statusData (#491).
702
705
  let progressHtml = "";
703
- if (sd) {
704
- const fillClass = pctClass(sd.progress);
706
+ const v2p = ls && ls._v2Progress;
707
+ const useV2 = v2p && v2p.total > 0 && ls.taskId === task.taskId;
708
+ if (task.status === "succeeded") {
709
+ // #491 fix: succeeded tasks always show 100%
705
710
  progressHtml = `
706
711
  <div class="task-progress">
707
- <div class="task-progress-bar">
708
- <div class="task-progress-fill ${fillClass}" style="width:${sd.progress}%"></div>
709
- </div>
710
- <span class="task-progress-text">${sd.progress}% ${sd.checked}/${sd.total}</span>
712
+ <div class="task-progress-bar"><div class="task-progress-fill pct-hi" style="width:100%"></div></div>
713
+ <span class="task-progress-text">100%</span>
711
714
  </div>`;
712
- } else if (task.status === "succeeded") {
715
+ } else if (sd || useV2) {
716
+ const displayChecked = useV2 ? v2p.checked : (sd ? sd.checked : 0);
717
+ const displayTotal = useV2 ? v2p.total : (sd ? sd.total : 0);
718
+ const displayProgress = displayTotal > 0 ? Math.round((displayChecked / displayTotal) * 100) : 0;
719
+ const fillClass = pctClass(displayProgress);
713
720
  progressHtml = `
714
721
  <div class="task-progress">
715
- <div class="task-progress-bar"><div class="task-progress-fill pct-hi" style="width:100%"></div></div>
716
- <span class="task-progress-text">100%</span>
722
+ <div class="task-progress-bar">
723
+ <div class="task-progress-fill ${fillClass}" style="width:${displayProgress}%"></div>
724
+ </div>
725
+ <span class="task-progress-text">${displayProgress}% ${displayChecked}/${displayTotal}</span>
717
726
  </div>`;
718
727
  } else if (task.status === "pending") {
719
728
  progressHtml = `
@@ -1881,6 +1890,127 @@ function renderV2Event(evt) {
1881
1890
  }
1882
1891
  }
1883
1892
 
1893
+ // ── Segment-Scoped STATUS.md Helpers (TP-176) ──────────────────────────────
1894
+
1895
+ /**
1896
+ * Resolve the active segment repoId for a given task.
1897
+ * Uses runtimeLaneSnapshots (active segment) and falls back to
1898
+ * taskSegmentProgress (batch state).
1899
+ * Returns { repoId, segmentInfo } or null if single-segment / unresolvable.
1900
+ */
1901
+ function resolveActiveSegmentForTask(taskId) {
1902
+ if (!currentData) return null;
1903
+ const batch = currentData.batch;
1904
+ if (!batch) return null;
1905
+ const task = (batch.tasks || []).find(t => t.taskId === taskId);
1906
+ if (!task) return null;
1907
+ const segmentIds = Array.isArray(task.segmentIds) ? task.segmentIds.filter(id => typeof id === "string") : [];
1908
+ if (segmentIds.length <= 1) return null; // single-segment or no segments
1909
+
1910
+ // Try to get active segment from runtime lane snapshots
1911
+ const v2Snapshots = currentData.runtimeLaneSnapshots || {};
1912
+ for (const snap of Object.values(v2Snapshots)) {
1913
+ if (snap && snap.taskId === taskId && snap.segmentId) {
1914
+ const parsed = parseSegmentId(snap.segmentId);
1915
+ if (parsed) {
1916
+ const idx = segmentIds.indexOf(snap.segmentId);
1917
+ return {
1918
+ repoId: parsed.repoId,
1919
+ segmentInfo: {
1920
+ index: idx >= 0 ? idx + 1 : null,
1921
+ total: segmentIds.length,
1922
+ repoId: parsed.repoId,
1923
+ segmentId: snap.segmentId,
1924
+ },
1925
+ };
1926
+ }
1927
+ }
1928
+ }
1929
+
1930
+ // Fallback: use taskSegmentProgress (batch state)
1931
+ const segmentStatusMap = buildSegmentStatusMap(batch);
1932
+ const info = taskSegmentProgress(task, segmentStatusMap, null);
1933
+ if (info && info.repoId) {
1934
+ return { repoId: info.repoId, segmentInfo: info };
1935
+ }
1936
+ return null;
1937
+ }
1938
+
1939
+ /**
1940
+ * Filter STATUS.md content to show only the active segment's blocks.
1941
+ * Within each `### Step N:` section, removes `#### Segment: <otherRepo>` blocks
1942
+ * and keeps only the block matching `activeRepoId`.
1943
+ * Non-step content (metadata, notes, reviews, etc.) is preserved.
1944
+ *
1945
+ * Returns the filtered markdown string, or the original if no segment markers found.
1946
+ */
1947
+ function filterStatusMdForSegment(markdown, activeRepoId) {
1948
+ if (!activeRepoId) return markdown;
1949
+ const lines = markdown.split('\n');
1950
+ const result = [];
1951
+ let inStep = false; // inside a ### Step section
1952
+ let inSegmentBlock = false; // inside a #### Segment: <repo> block
1953
+ let segmentMatch = false; // current segment block matches active repo
1954
+ let foundAnySegmentHeader = false;
1955
+
1956
+ for (let i = 0; i < lines.length; i++) {
1957
+ const line = lines[i];
1958
+
1959
+ // Detect step headers: ### Step N: ...
1960
+ if (/^###\s+Step\s+\d+/.test(line)) {
1961
+ inStep = true;
1962
+ inSegmentBlock = false;
1963
+ segmentMatch = false;
1964
+ result.push(line);
1965
+ continue;
1966
+ }
1967
+
1968
+ // Detect non-step ### headers (e.g., ### Reviews, ### Notes)
1969
+ if (/^###\s+/.test(line) && !/^###\s+Step\s+\d+/.test(line)) {
1970
+ inStep = false;
1971
+ inSegmentBlock = false;
1972
+ segmentMatch = false;
1973
+ result.push(line);
1974
+ continue;
1975
+ }
1976
+
1977
+ // Inside a step section, detect #### Segment: <repoId> headers
1978
+ if (inStep && /^####\s+Segment:\s*/.test(line)) {
1979
+ foundAnySegmentHeader = true;
1980
+ const segRepo = line.replace(/^####\s+Segment:\s*/, '').trim();
1981
+ inSegmentBlock = true;
1982
+ segmentMatch = (segRepo === activeRepoId);
1983
+ if (segmentMatch) {
1984
+ result.push(line);
1985
+ }
1986
+ continue;
1987
+ }
1988
+
1989
+ // Detect any other #### header (ends current segment block)
1990
+ if (/^####\s+/.test(line)) {
1991
+ inSegmentBlock = false;
1992
+ segmentMatch = false;
1993
+ result.push(line);
1994
+ continue;
1995
+ }
1996
+
1997
+ // If we're in a segment block, only include matching lines
1998
+ if (inSegmentBlock) {
1999
+ if (segmentMatch) {
2000
+ result.push(line);
2001
+ }
2002
+ continue;
2003
+ }
2004
+
2005
+ // Outside segment blocks: keep the line
2006
+ result.push(line);
2007
+ }
2008
+
2009
+ // If no segment headers were found, return original (fallback for single-segment)
2010
+ if (!foundAnySegmentHeader) return markdown;
2011
+ return result.join('\n');
2012
+ }
2013
+
1884
2014
  // ── Open STATUS.md viewer ───────────────────────────────────────────────────
1885
2015
 
1886
2016
  function viewStatusMd(taskId) {
@@ -1897,7 +2027,15 @@ function viewStatusMd(taskId) {
1897
2027
  autoScrollOn = false;
1898
2028
  lastStatusMdText = '';
1899
2029
 
1900
- $terminalTitle.textContent = `STATUS.md ${taskId}`;
2030
+ // TP-176: Include segment context in title for multi-segment tasks
2031
+ const segData = resolveActiveSegmentForTask(taskId);
2032
+ if (segData && segData.segmentInfo) {
2033
+ const label = segmentProgressText(segData.segmentInfo);
2034
+ $terminalTitle.textContent = `STATUS.md — ${taskId} · ${label}`;
2035
+ } else {
2036
+ $terminalTitle.textContent = `STATUS.md — ${taskId}`;
2037
+ }
2038
+
1901
2039
  $autoScrollText.textContent = 'Track progress';
1902
2040
  $autoScrollCheckbox.checked = false;
1903
2041
  $terminalPanel.style.display = '';
@@ -1916,11 +2054,22 @@ function pollStatusMd() {
1916
2054
  return r.text();
1917
2055
  })
1918
2056
  .then(text => {
2057
+ // TP-176: Apply segment-scoped filtering for multi-segment tasks.
2058
+ // Re-resolve on each poll since the active segment may change.
2059
+ const segData = resolveActiveSegmentForTask(viewerTarget);
2060
+ let displayText = text;
2061
+ if (segData && segData.repoId) {
2062
+ displayText = filterStatusMdForSegment(text, segData.repoId);
2063
+ // Update title with current segment context (may change between polls)
2064
+ const label = segmentProgressText(segData.segmentInfo);
2065
+ $terminalTitle.textContent = `STATUS.md \u2014 ${viewerTarget} \u00b7 ${label}`;
2066
+ }
2067
+
1919
2068
  // Diff-and-skip: no change, no DOM update
1920
- if (text === lastStatusMdText) return;
1921
- lastStatusMdText = text;
2069
+ if (displayText === lastStatusMdText) return;
2070
+ lastStatusMdText = displayText;
1922
2071
 
1923
- const { html, hasLastChecked } = renderStatusMd(text);
2072
+ const { html, hasLastChecked } = renderStatusMd(displayText);
1924
2073
  $terminalBody.innerHTML = html;
1925
2074
 
1926
2075
  // Update tracking highlight
@@ -85,7 +85,8 @@ export function selectAbortTargetSessions(
85
85
  laneId: lane.laneId,
86
86
  taskId: currentTask?.taskId || null,
87
87
  worktreePath: lane.worktreePath,
88
- taskFolder: currentTask?.task.taskFolder || null,
88
+ // TP-169: Guard against null task stubs from reconstructAllocatedLanes
89
+ taskFolder: currentTask?.task?.taskFolder || null,
89
90
  });
90
91
  }
91
92
 
@@ -136,6 +136,23 @@ export interface AgentHostOptions {
136
136
  packet?: PacketPaths | null;
137
137
  /** Extra environment variables for the child process */
138
138
  env?: Record<string, string>;
139
+ /**
140
+ * Callback invoked when agent_end fires, before stdin is closed.
141
+ * Receives the last assistant message text.
142
+ * Return a string to send as a new prompt (re-prompt the agent),
143
+ * or null to close the session normally.
144
+ *
145
+ * @since TP-172
146
+ */
147
+ onPrematureExit?: (assistantMessage: string) => Promise<string | null>;
148
+ /**
149
+ * Maximum number of exit interceptions before forcing session close.
150
+ * Prevents infinite loops where the callback always returns a new prompt.
151
+ * Default: 2
152
+ *
153
+ * @since TP-172
154
+ */
155
+ maxExitInterceptions?: number;
139
156
  }
140
157
 
141
158
  /**
@@ -235,6 +252,7 @@ export function spawnAgent(
235
252
  const cliPath = resolvePiCliPath();
236
253
  const closeDelayMs = opts.closeDelayMs ?? 100;
237
254
  const timeoutMs = opts.timeoutMs ?? 0;
255
+ const maxExitInterceptions = opts.maxExitInterceptions ?? 3;
238
256
 
239
257
  // Build Pi CLI arguments
240
258
  const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
@@ -275,6 +293,12 @@ export function spawnAgent(
275
293
  let contextUsage: AgentHostResult["contextUsage"] = null;
276
294
  let stderrBuffer = "";
277
295
  const STDERR_MAX = 2048;
296
+ /** Last assistant message text captured from message_end events (TP-172) */
297
+ let lastAssistantMessage = "";
298
+ /** Number of times exit interception has occurred (TP-172) */
299
+ let exitInterceptionCount = 0;
300
+ /** Whether the current turn had any tool calls (TP-172: text-only gate) */
301
+ let currentTurnHadToolCalls = false;
278
302
 
279
303
  // Timeout
280
304
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
@@ -538,6 +562,8 @@ export function spawnAgent(
538
562
  const content = extractAssistantText(event.message);
539
563
  if (content) {
540
564
  emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
565
+ // TP-172: Track last assistant message for exit interception
566
+ lastAssistantMessage = content;
541
567
  }
542
568
  }
543
569
  // Request session stats immediately on first assistant message,
@@ -560,6 +586,7 @@ export function spawnAgent(
560
586
  }
561
587
  case "tool_execution_start": {
562
588
  toolCalls++;
589
+ currentTurnHadToolCalls = true;
563
590
  const toolName = event.toolName || "tool";
564
591
  const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
565
592
  (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
@@ -602,7 +629,79 @@ export function spawnAgent(
602
629
  }
603
630
  case "agent_end": {
604
631
  agentEnded = true;
605
- closeStdin();
632
+ // TP-172: Exit interception — intercept any exit when callback
633
+ // is provided and under limit. The callback (lane-runner) decides
634
+ // whether the worker made progress. We don't gate on tool calls
635
+ // because workers commonly use tools (reads/greps) then exit
636
+ // with a text declaration ("Now let me fix this:") without
637
+ // actually making the edit.
638
+ const shouldIntercept = opts.onPrematureExit
639
+ && exitInterceptionCount < maxExitInterceptions;
640
+ if (shouldIntercept) {
641
+ exitInterceptionCount++;
642
+ const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
643
+ // Wrap in Promise.resolve().then() to catch synchronous throws
644
+ const interceptPromise = Promise.resolve().then(() =>
645
+ opts.onPrematureExit!(lastAssistantMessage));
646
+ const timeoutPromise = new Promise<null>((res) =>
647
+ setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS));
648
+ Promise.race([interceptPromise, timeoutPromise])
649
+ .then(
650
+ (newPrompt: string | null) => {
651
+ if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) {
652
+ // Re-prompt the agent with supervisor guidance
653
+ agentEnded = false; // Reset for the new turn
654
+ currentTurnHadToolCalls = false; // Reset for new turn
655
+ proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n");
656
+ emitEvent("exit_intercepted", {
657
+ interceptionCount: exitInterceptionCount,
658
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
659
+ supervisorConsulted: true,
660
+ action: "reprompt",
661
+ newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS),
662
+ });
663
+ } else {
664
+ // Callback returned null or stdin already closed — close session
665
+ const reason = stdinClosed ? "stdin_closed"
666
+ : newPrompt === null ? "callback_returned_null"
667
+ : "unknown";
668
+ emitEvent("exit_intercepted", {
669
+ interceptionCount: exitInterceptionCount,
670
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
671
+ supervisorConsulted: true,
672
+ action: "close",
673
+ reason,
674
+ });
675
+ closeStdin();
676
+ }
677
+ },
678
+ (err: unknown) => {
679
+ // Callback rejected — emit single diagnostic event and close
680
+ const msg = err instanceof Error ? err.message : String(err);
681
+ emitEvent("exit_intercepted", {
682
+ interceptionCount: exitInterceptionCount,
683
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
684
+ supervisorConsulted: false,
685
+ action: "close",
686
+ reason: "callback_error",
687
+ error: msg,
688
+ });
689
+ closeStdin();
690
+ },
691
+ );
692
+ } else {
693
+ // No callback, had tool calls, or interception limit reached — close normally
694
+ if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) {
695
+ emitEvent("exit_intercepted", {
696
+ interceptionCount: exitInterceptionCount,
697
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
698
+ supervisorConsulted: false,
699
+ action: "close",
700
+ reason: "max_interceptions_reached",
701
+ });
702
+ }
703
+ closeStdin();
704
+ }
606
705
  break;
607
706
  }
608
707
  }