taskplane 0.6.0 → 0.7.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.
@@ -507,6 +507,222 @@ function loadTelemetryData(batchState) {
507
507
  return result;
508
508
  }
509
509
 
510
+ // ─── Supervisor Data Loading ────────────────────────────────────────────────
511
+
512
+ /**
513
+ * Module-level tail state for supervisor JSONL files (actions.jsonl, events.jsonl).
514
+ * Reuses the same incremental tailing pattern as telemetry.
515
+ * Key: absolute file path → { offset, partial, entries }
516
+ */
517
+ const supervisorTailStates = {
518
+ actions: { offset: 0, partial: "", entries: [] },
519
+ events: { offset: 0, partial: "", entries: [] },
520
+ conversation: { offset: 0, partial: "", entries: [] },
521
+ };
522
+
523
+ /**
524
+ * The last known batchId — used to detect batch changes and reset accumulators.
525
+ */
526
+ let supervisorLastBatchId = "";
527
+
528
+ /**
529
+ * Incrementally tail a JSONL file, accumulating parsed entries.
530
+ * Filters entries by batchId when provided.
531
+ *
532
+ * @param {string} filePath - Absolute path to the JSONL file
533
+ * @param {object} tailState - Mutable tail state { offset, partial, entries }
534
+ * @param {string} batchId - Batch ID to filter by (empty = no filter)
535
+ * @returns {object[]} The accumulated entries array (same reference as tailState.entries)
536
+ */
537
+ function tailSupervisorJsonl(filePath, tailState, batchId) {
538
+ // Check file size
539
+ let fileSize;
540
+ try {
541
+ fileSize = fs.statSync(filePath).size;
542
+ } catch {
543
+ return tailState.entries; // File doesn't exist yet — return accumulated
544
+ }
545
+
546
+ // Handle file truncation/recreation
547
+ if (fileSize < tailState.offset) {
548
+ tailState.offset = 0;
549
+ tailState.partial = "";
550
+ tailState.entries = [];
551
+ }
552
+
553
+ if (fileSize <= tailState.offset) {
554
+ return tailState.entries; // No new data
555
+ }
556
+
557
+ // Read new bytes from offset
558
+ const bytesToRead = fileSize - tailState.offset;
559
+ const buf = Buffer.alloc(bytesToRead);
560
+ let fd;
561
+ try {
562
+ fd = fs.openSync(filePath, "r");
563
+ } catch {
564
+ return tailState.entries;
565
+ }
566
+ try {
567
+ fs.readSync(fd, buf, 0, bytesToRead, tailState.offset);
568
+ } catch {
569
+ fs.closeSync(fd);
570
+ return tailState.entries;
571
+ }
572
+ fs.closeSync(fd);
573
+ tailState.offset = fileSize;
574
+
575
+ // Split into lines, preserving partial trailing line
576
+ const chunk = tailState.partial + buf.toString("utf-8");
577
+ const lines = chunk.split("\n");
578
+ tailState.partial = lines.pop() || "";
579
+
580
+ for (const line of lines) {
581
+ const trimmed = line.trim();
582
+ if (!trimmed) continue;
583
+ try {
584
+ const entry = JSON.parse(trimmed);
585
+ // Filter by batchId if provided
586
+ if (batchId && entry.batchId && entry.batchId !== batchId) continue;
587
+ tailState.entries.push(entry);
588
+ } catch {
589
+ // Malformed JSON — skip
590
+ }
591
+ }
592
+
593
+ // Cap accumulated entries to prevent unbounded growth (keep last 500)
594
+ if (tailState.entries.length > 500) {
595
+ tailState.entries = tailState.entries.slice(-500);
596
+ }
597
+
598
+ return tailState.entries;
599
+ }
600
+
601
+ /**
602
+ * Read supervisor autonomy level from project config.
603
+ *
604
+ * Checks `.pi/taskplane-config.json` for `orchestrator.supervisor.autonomy`.
605
+ * Falls back to "supervised" (the default) if config is missing or malformed.
606
+ * This is needed because the lockfile does not contain the autonomy level.
607
+ *
608
+ * @returns {string} Autonomy level: "interactive" | "supervised" | "autonomous"
609
+ */
610
+ function loadSupervisorAutonomy() {
611
+ try {
612
+ const configPath = path.join(REPO_ROOT, ".pi", "taskplane-config.json");
613
+ const raw = fs.readFileSync(configPath, "utf-8");
614
+ const config = JSON.parse(raw);
615
+ const autonomy = config?.orchestrator?.supervisor?.autonomy;
616
+ if (autonomy === "interactive" || autonomy === "supervised" || autonomy === "autonomous") {
617
+ return autonomy;
618
+ }
619
+ } catch {
620
+ // Config missing or malformed — use default
621
+ }
622
+ return "supervised"; // Default per DEFAULT_SUPERVISOR_CONFIG
623
+ }
624
+
625
+ /**
626
+ * Load supervisor data for the dashboard.
627
+ *
628
+ * Reads (all from .pi/supervisor/):
629
+ * - lock.json: supervisor active/stale status, heartbeat, autonomy (from config)
630
+ * - actions.jsonl: recovery action audit trail (batch-scoped, incremental)
631
+ * - events.jsonl: engine + tier 0 events (batch-scoped, incremental)
632
+ * - conversation.jsonl: operator ↔ supervisor interaction log (spec §9.1)
633
+ * - summary.md: human-readable batch summary (generated on completion)
634
+ *
635
+ * Returns null when no supervisor files exist (pre-supervisor batches).
636
+ *
637
+ * @param {object|null} batchState - The batch state from batch-state.json
638
+ * @returns {object|null} Supervisor data object or null
639
+ */
640
+ function loadSupervisorData(batchState) {
641
+ const supervisorDir = path.join(REPO_ROOT, ".pi", "supervisor");
642
+ const batchId = batchState ? (batchState.batchId || "") : "";
643
+
644
+ // Detect batch change — reset tail state accumulators
645
+ if (batchId && batchId !== supervisorLastBatchId) {
646
+ supervisorLastBatchId = batchId;
647
+ supervisorTailStates.actions = { offset: 0, partial: "", entries: [] };
648
+ supervisorTailStates.events = { offset: 0, partial: "", entries: [] };
649
+ supervisorTailStates.conversation = { offset: 0, partial: "", entries: [] };
650
+ }
651
+
652
+ // ── Lockfile: supervisor status ──
653
+ // The lockfile contains pid, sessionId, batchId, startedAt, heartbeat.
654
+ // It does NOT contain autonomy — that comes from project config.
655
+ let lock = null;
656
+ try {
657
+ const lockPath = path.join(supervisorDir, "lock.json");
658
+ const raw = fs.readFileSync(lockPath, "utf-8");
659
+ const parsed = JSON.parse(raw);
660
+ if (parsed && parsed.pid && parsed.sessionId) {
661
+ // Determine if lock is stale (heartbeat older than 90s)
662
+ const heartbeatAge = parsed.heartbeat
663
+ ? Date.now() - new Date(parsed.heartbeat).getTime()
664
+ : Infinity;
665
+ const isStale = heartbeatAge > 90_000;
666
+
667
+ lock = {
668
+ active: !isStale,
669
+ pid: parsed.pid,
670
+ sessionId: parsed.sessionId,
671
+ batchId: parsed.batchId || "",
672
+ startedAt: parsed.startedAt || "",
673
+ heartbeat: parsed.heartbeat || "",
674
+ // Autonomy is NOT in the lockfile — derive from project config
675
+ autonomy: loadSupervisorAutonomy(),
676
+ };
677
+ }
678
+ } catch {
679
+ // No lockfile or malformed — supervisor is inactive
680
+ }
681
+
682
+ // ── Actions JSONL: recovery audit trail (batch-scoped, incremental) ──
683
+ const actionsPath = path.join(supervisorDir, "actions.jsonl");
684
+ const actions = tailSupervisorJsonl(actionsPath, supervisorTailStates.actions, batchId);
685
+
686
+ // ── Events JSONL: engine events (batch-scoped, incremental) ──
687
+ const eventsPath = path.join(supervisorDir, "events.jsonl");
688
+ const events = tailSupervisorJsonl(eventsPath, supervisorTailStates.events, batchId);
689
+
690
+ // ── Conversation JSONL: operator interaction log (spec §9.1) ──
691
+ // The supervisor writes operator↔supervisor messages to conversation.jsonl.
692
+ // Not yet implemented in all supervisor versions — degrade gracefully.
693
+ const conversationPath = path.join(supervisorDir, "conversation.jsonl");
694
+ const conversation = tailSupervisorJsonl(conversationPath, supervisorTailStates.conversation, batchId);
695
+
696
+ // ── Summary: human-readable batch summary (generated on completion) ──
697
+ // Per spec §9.1, the supervisor writes .pi/supervisor/summary.md when the
698
+ // batch completes or is abandoned. Read the file if it exists.
699
+ let summary = null;
700
+ try {
701
+ const summaryPath = path.join(supervisorDir, "summary.md");
702
+ summary = fs.readFileSync(summaryPath, "utf-8");
703
+ } catch {
704
+ // No summary yet — batch may still be running, or pre-supervisor batch
705
+ }
706
+
707
+ // If nothing exists at all, return null (pre-supervisor batch)
708
+ if (!lock && actions.length === 0 && events.length === 0 && conversation.length === 0 && !summary) {
709
+ // Check if the supervisor directory even exists
710
+ try {
711
+ fs.statSync(supervisorDir);
712
+ } catch {
713
+ return null; // No supervisor dir → pre-supervisor batch
714
+ }
715
+ }
716
+
717
+ return {
718
+ lock,
719
+ actions,
720
+ events,
721
+ conversation,
722
+ summary,
723
+ };
724
+ }
725
+
510
726
  /**
511
727
  * Compute batch total cost from lane states (primary) and telemetry (supplementary).
512
728
  * Lane states are authoritative — telemetry provides additional data only for lanes
@@ -541,9 +757,10 @@ function buildDashboardState() {
541
757
  const laneStates = loadLaneStates();
542
758
  const telemetry = loadTelemetryData(state);
543
759
  const batchTotalCost = computeBatchTotalCost(laneStates, telemetry);
760
+ const supervisor = loadSupervisorData(state);
544
761
 
545
762
  if (!state) {
546
- return { batch: null, tmuxSessions, laneStates: {}, telemetry: {}, batchTotalCost: 0, timestamp: Date.now() };
763
+ return { batch: null, tmuxSessions, laneStates: {}, telemetry: {}, batchTotalCost: 0, supervisor: null, timestamp: Date.now() };
547
764
  }
548
765
 
549
766
  const tasks = (state.tasks || []).map((task) => {
@@ -562,6 +779,7 @@ function buildDashboardState() {
562
779
  laneStates,
563
780
  telemetry,
564
781
  batchTotalCost,
782
+ supervisor,
565
783
  batch: {
566
784
  batchId: state.batchId,
567
785
  phase: state.phase,
@@ -1406,6 +1406,20 @@ export const _readExitSummary = readExitSummary;
1406
1406
  export const _buildExitDiagnostic = buildExitDiagnostic;
1407
1407
  export type { BuildExitDiagnosticInput };
1408
1408
 
1409
+ /**
1410
+ * Determine whether a step is "low-risk" and should skip reviews.
1411
+ * Low-risk steps: Step 0 (Preflight) and the final step (Delivery/Docs).
1412
+ *
1413
+ * @param stepNumber The 0-based step number being evaluated
1414
+ * @param totalSteps Total number of steps in the task
1415
+ * @returns true if the step should skip plan and code reviews
1416
+ */
1417
+ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
1418
+ if (totalSteps <= 0) return false;
1419
+ const lastStepIndex = totalSteps - 1;
1420
+ return stepNumber === 0 || stepNumber === lastStepIndex;
1421
+ }
1422
+
1409
1423
  // ── TMUX Agent Spawner ───────────────────────────────────────────────
1410
1424
 
1411
1425
  /**
@@ -2081,11 +2095,20 @@ export default function (pi: ExtensionAPI) {
2081
2095
  logExecution(statusPath, `Step ${step.number} started`, step.name);
2082
2096
  updateWidgets();
2083
2097
 
2098
+ // Skip reviews for low-risk steps (Step 0 / Preflight and final step / Delivery)
2099
+ const _isLowRiskStep = isLowRiskStep(step.number, task.steps.length);
2100
+
2084
2101
  // Plan review (level ≥ 1)
2085
2102
  if (task.reviewLevel >= 1) {
2086
- const verdict = await doReview("plan", step, ctx, stepBaselineCommit);
2087
- if (verdict === "RETHINK") {
2088
- ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
2103
+ if (_isLowRiskStep) {
2104
+ const label = step.number === 0 ? "Preflight" : "final step";
2105
+ logExecution(statusPath, `Skip plan review`, `Step ${step.number} (${label}) low-risk`);
2106
+ ctx.ui.notify(`⏭️ Skipping plan review for Step ${step.number} (${label})`, "info");
2107
+ } else {
2108
+ const verdict = await doReview("plan", step, ctx, stepBaselineCommit);
2109
+ if (verdict === "RETHINK") {
2110
+ ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
2111
+ }
2089
2112
  }
2090
2113
  }
2091
2114
 
@@ -2135,10 +2158,16 @@ export default function (pi: ExtensionAPI) {
2135
2158
 
2136
2159
  // Code review (level ≥ 2)
2137
2160
  if (task.reviewLevel >= 2 && state.phase === "running") {
2138
- const verdict = await doReview("code", step, ctx, stepBaselineCommit);
2139
- if (verdict === "REVISE") {
2140
- ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Running worker to fix...`, "warning");
2141
- await runWorker(step, ctx); // One more pass to address issues
2161
+ if (_isLowRiskStep) {
2162
+ const label = step.number === 0 ? "Preflight" : "final step";
2163
+ logExecution(statusPath, `Skip code review`, `Step ${step.number} (${label}) low-risk`);
2164
+ ctx.ui.notify(`⏭️ Skipping code review for Step ${step.number} (${label})`, "info");
2165
+ } else {
2166
+ const verdict = await doReview("code", step, ctx, stepBaselineCommit);
2167
+ if (verdict === "REVISE") {
2168
+ ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Running worker to fix...`, "warning");
2169
+ await runWorker(step, ctx); // One more pass to address issues
2170
+ }
2142
2171
  }
2143
2172
  }
2144
2173
 
@@ -261,6 +261,9 @@ function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
261
261
  // verification: all keys are structural (TP-032)
262
262
  if (raw.verification) result.verification = convertStructuralKeys(raw.verification);
263
263
 
264
+ // supervisor: all keys are structural (TP-041)
265
+ if (raw.supervisor) result.supervisor = convertStructuralKeys(raw.supervisor);
266
+
264
267
  return result;
265
268
  }
266
269
 
@@ -515,6 +518,7 @@ function extractAllowlistedPreferences(raw: Record<string, any>): UserPreference
515
518
  if (typeof raw.workerModel === "string") prefs.workerModel = raw.workerModel;
516
519
  if (typeof raw.reviewerModel === "string") prefs.reviewerModel = raw.reviewerModel;
517
520
  if (typeof raw.mergeModel === "string") prefs.mergeModel = raw.mergeModel;
521
+ if (typeof raw.supervisorModel === "string") prefs.supervisorModel = raw.supervisorModel;
518
522
  if (typeof raw.dashboardPort === "number" && Number.isFinite(raw.dashboardPort)) {
519
523
  prefs.dashboardPort = raw.dashboardPort;
520
524
  }
@@ -541,6 +545,7 @@ function extractAllowlistedPreferences(raw: Record<string, any>): UserPreference
541
545
  * prefs.workerModel → config.taskRunner.worker.model
542
546
  * prefs.reviewerModel → config.taskRunner.reviewer.model
543
547
  * prefs.mergeModel → config.orchestrator.merge.model
548
+ * prefs.supervisorModel → config.orchestrator.supervisor.model
544
549
  * prefs.dashboardPort → (no config target yet — stored only)
545
550
  */
546
551
  export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPreferences): TaskplaneConfig {
@@ -554,6 +559,7 @@ export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPrefere
554
559
  applyStr(prefs.workerModel, (v) => { config.taskRunner.worker.model = v; });
555
560
  applyStr(prefs.reviewerModel, (v) => { config.taskRunner.reviewer.model = v; });
556
561
  applyStr(prefs.mergeModel, (v) => { config.orchestrator.merge.model = v; });
562
+ applyStr(prefs.supervisorModel, (v) => { config.orchestrator.supervisor.model = v; });
557
563
 
558
564
  // spawnMode: enum — apply if defined (not a string-empty check)
559
565
  if (prefs.spawnMode !== undefined) {
@@ -578,7 +584,7 @@ export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPrefere
578
584
  * `<configRepo>/.taskplane/`) where files are scaffolded directly
579
585
  * without a `.pi/` subdirectory.
580
586
  */
581
- function hasConfigFiles(root: string): boolean {
587
+ export function hasConfigFiles(root: string): boolean {
582
588
  const files = [PROJECT_CONFIG_FILENAME, "task-runner.yaml", "task-orchestrator.yaml"];
583
589
  for (const f of files) {
584
590
  if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true;
@@ -241,8 +241,8 @@ export interface OrchestratorCoreConfig {
241
241
  tmuxPrefix: string;
242
242
  /** Operator identifier. Auto-detected from OS username if empty */
243
243
  operatorId: string;
244
- /** How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion. */
245
- integration: "manual" | "auto";
244
+ /** How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking. */
245
+ integration: "manual" | "supervised" | "auto";
246
246
  }
247
247
 
248
248
  /** Dependency resolution settings */
@@ -360,6 +360,14 @@ export interface VerificationConfig {
360
360
  /**
361
361
  * All orchestrator settings, previously from `.pi/task-orchestrator.yaml`.
362
362
  */
363
+ /** Supervisor agent settings (TP-041). */
364
+ export interface SupervisorSectionConfig {
365
+ /** Supervisor model (empty = inherit active session model) */
366
+ model: string;
367
+ /** Autonomy level for recovery actions */
368
+ autonomy: "interactive" | "supervised" | "autonomous";
369
+ }
370
+
363
371
  export interface OrchestratorSection {
364
372
  /** Core orchestrator settings */
365
373
  orchestrator: OrchestratorCoreConfig;
@@ -377,6 +385,8 @@ export interface OrchestratorSection {
377
385
  monitoring: MonitoringConfig;
378
386
  /** Verification baseline fingerprinting (TP-032) */
379
387
  verification: VerificationConfig;
388
+ /** Supervisor agent (TP-041) */
389
+ supervisor: SupervisorSectionConfig;
380
390
  }
381
391
 
382
392
 
@@ -435,6 +445,7 @@ export interface TaskplaneConfig {
435
445
  * | workerModel | taskRunner.worker.model | string |
436
446
  * | reviewerModel | taskRunner.reviewer.model | string |
437
447
  * | mergeModel | orchestrator.merge.model | string |
448
+ * | supervisorModel | orchestrator.supervisor.model | string |
438
449
  * | dashboardPort | (preferences-only; not yet in schema)| number |
439
450
  */
440
451
  export interface UserPreferences {
@@ -450,6 +461,8 @@ export interface UserPreferences {
450
461
  reviewerModel?: string;
451
462
  /** Merge model override (overrides orchestrator.merge.model) */
452
463
  mergeModel?: string;
464
+ /** Supervisor model override (overrides orchestrator.supervisor.model) (TP-041) */
465
+ supervisorModel?: string;
453
466
  /** Dashboard port (preferences-only; not yet wired into config schema) */
454
467
  dashboardPort?: number;
455
468
  }
@@ -549,6 +562,10 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
549
562
  mode: "permissive",
550
563
  flakyReruns: 1,
551
564
  },
565
+ supervisor: {
566
+ model: "",
567
+ autonomy: "supervised",
568
+ },
552
569
  };
553
570
 
554
571
  /** Default unified config */
@@ -11,8 +11,11 @@
11
11
  * @module orch/config
12
12
  */
13
13
 
14
- import { loadProjectConfig, toOrchestratorConfig, toTaskRunnerConfig } from "./config-loader.ts";
14
+ import { loadProjectConfig, toOrchestratorConfig, toTaskRunnerConfig, hasConfigFiles } from "./config-loader.ts";
15
+ export { hasConfigFiles } from "./config-loader.ts";
15
16
  import type { OrchestratorConfig, TaskRunnerConfig } from "./types.ts";
17
+ import type { SupervisorConfig } from "./supervisor.ts";
18
+ import { DEFAULT_SUPERVISOR_CONFIG } from "./supervisor.ts";
16
19
 
17
20
  // ── Config Loading ───────────────────────────────────────────────────
18
21
 
@@ -49,3 +52,22 @@ export function loadTaskRunnerConfig(cwd: string, pointerConfigRoot?: string): T
49
52
  const unified = loadProjectConfig(cwd, pointerConfigRoot);
50
53
  return toTaskRunnerConfig(unified);
51
54
  }
55
+
56
+ /**
57
+ * Load supervisor config from unified project config.
58
+ *
59
+ * Extracts the `orchestrator.supervisor` section from the unified config.
60
+ * Falls back to defaults if the section is missing (backward compatibility
61
+ * with configs created before TP-041).
62
+ *
63
+ * @since TP-041
64
+ */
65
+ export function loadSupervisorConfig(cwd: string, pointerConfigRoot?: string): SupervisorConfig {
66
+ const unified = loadProjectConfig(cwd, pointerConfigRoot);
67
+ const section = unified.orchestrator.supervisor;
68
+ if (!section) return { ...DEFAULT_SUPERVISOR_CONFIG };
69
+ return {
70
+ model: section.model ?? DEFAULT_SUPERVISOR_CONFIG.model,
71
+ autonomy: section.autonomy ?? DEFAULT_SUPERVISOR_CONFIG.autonomy,
72
+ };
73
+ }