taskplane 0.29.2 → 0.30.1

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.
Files changed (43) hide show
  1. package/bin/gitignore-patterns.mjs +11 -8
  2. package/bin/rpc-wrapper.mjs +410 -357
  3. package/bin/taskplane.mjs +533 -250
  4. package/dashboard/public/app.js +124 -15
  5. package/dashboard/public/style.css +83 -2
  6. package/extensions/reviewer-extension.ts +17 -11
  7. package/extensions/taskplane/abort.ts +50 -18
  8. package/extensions/taskplane/agent-bridge-extension.ts +232 -105
  9. package/extensions/taskplane/agent-host.ts +224 -97
  10. package/extensions/taskplane/cleanup.ts +71 -42
  11. package/extensions/taskplane/config-loader.ts +142 -58
  12. package/extensions/taskplane/config-schema.ts +6 -13
  13. package/extensions/taskplane/config.ts +10 -2
  14. package/extensions/taskplane/diagnostic-reports.ts +59 -47
  15. package/extensions/taskplane/diagnostics.ts +13 -13
  16. package/extensions/taskplane/discovery.ts +78 -63
  17. package/extensions/taskplane/engine-worker.ts +53 -46
  18. package/extensions/taskplane/engine.ts +1760 -602
  19. package/extensions/taskplane/execution.ts +469 -207
  20. package/extensions/taskplane/extension.ts +1073 -598
  21. package/extensions/taskplane/formatting.ts +136 -124
  22. package/extensions/taskplane/git.ts +0 -2
  23. package/extensions/taskplane/lane-runner.ts +652 -319
  24. package/extensions/taskplane/mailbox.ts +57 -49
  25. package/extensions/taskplane/merge.ts +662 -383
  26. package/extensions/taskplane/messages.ts +109 -51
  27. package/extensions/taskplane/migrations.ts +1 -1
  28. package/extensions/taskplane/path-resolver.ts +8 -9
  29. package/extensions/taskplane/persistence.ts +425 -262
  30. package/extensions/taskplane/process-registry.ts +36 -7
  31. package/extensions/taskplane/quality-gate.ts +107 -55
  32. package/extensions/taskplane/resume.ts +832 -280
  33. package/extensions/taskplane/sessions.ts +1 -1
  34. package/extensions/taskplane/settings-tui.ts +505 -164
  35. package/extensions/taskplane/sidecar-telemetry.ts +25 -10
  36. package/extensions/taskplane/supervisor.ts +477 -270
  37. package/extensions/taskplane/task-executor-core.ts +178 -53
  38. package/extensions/taskplane/types.ts +209 -108
  39. package/extensions/taskplane/verification.ts +27 -22
  40. package/extensions/taskplane/waves.ts +59 -43
  41. package/extensions/taskplane/workspace.ts +14 -12
  42. package/extensions/taskplane/worktree.ts +218 -196
  43. package/package.json +14 -2
@@ -66,7 +66,6 @@ export const CONFIG_VERSION = 1;
66
66
  */
67
67
  export const PROJECT_CONFIG_FILENAME = "taskplane-config.json";
68
68
 
69
-
70
69
  // ── Task Runner Section Interfaces ───────────────────────────────────
71
70
 
72
71
  /** Project metadata */
@@ -207,7 +206,6 @@ export interface QualityGateConfig {
207
206
  passThreshold: PassThreshold;
208
207
  }
209
208
 
210
-
211
209
  // ── Task Runner Combined Section ─────────────────────────────────────
212
210
 
213
211
  /**
@@ -257,7 +255,6 @@ export interface TaskRunnerSection {
257
255
  modelFallback: ModelFallbackMode;
258
256
  }
259
257
 
260
-
261
258
  // ── Orchestrator Section Interfaces ──────────────────────────────────
262
259
 
263
260
  /** Core orchestrator settings */
@@ -393,7 +390,6 @@ export interface VerificationConfig {
393
390
  flakyReruns: number;
394
391
  }
395
392
 
396
-
397
393
  // ── Orchestrator Combined Section ────────────────────────────────────
398
394
 
399
395
  /**
@@ -428,7 +424,6 @@ export interface OrchestratorSection {
428
424
  supervisor: SupervisorSectionConfig;
429
425
  }
430
426
 
431
-
432
427
  // ── Workspace Section Interfaces ─────────────────────────────────────
433
428
 
434
429
  /** Workspace repo definition (JSON config shape). */
@@ -459,7 +454,6 @@ export interface WorkspaceSectionConfig {
459
454
  routing: WorkspaceRoutingSectionConfig;
460
455
  }
461
456
 
462
-
463
457
  // ── Unified Config ───────────────────────────────────────────────────
464
458
 
465
459
  /**
@@ -491,7 +485,6 @@ export interface TaskplaneConfig {
491
485
  workspace?: WorkspaceSectionConfig;
492
486
  }
493
487
 
494
-
495
488
  // ── Global Preferences (Layer 2) ─────────────────────────────────────
496
489
 
497
490
  /**
@@ -527,11 +520,12 @@ export interface InitAgentDefaultsPreferences {
527
520
  mergeThinking?: string;
528
521
  }
529
522
 
530
- export type DeepPartial<T> = T extends Array<infer U>
531
- ? Array<DeepPartial<U>>
532
- : T extends object
533
- ? { [K in keyof T]?: DeepPartial<T[K]> }
534
- : T;
523
+ export type DeepPartial<T> =
524
+ T extends Array<infer U>
525
+ ? Array<DeepPartial<U>>
526
+ : T extends object
527
+ ? { [K in keyof T]?: DeepPartial<T[K]> }
528
+ : T;
535
529
 
536
530
  export interface GlobalPreferences {
537
531
  /**
@@ -590,7 +584,6 @@ export const GLOBAL_PREFERENCES_FILENAME = "preferences.json";
590
584
  */
591
585
  export const GLOBAL_PREFERENCES_SUBDIR = "taskplane";
592
586
 
593
-
594
587
  // ── Defaults ─────────────────────────────────────────────────────────
595
588
 
596
589
  /** Default task runner section values */
@@ -11,7 +11,12 @@
11
11
  * @module orch/config
12
12
  */
13
13
 
14
- import { loadProjectConfig, toOrchestratorConfig, toTaskRunnerConfig, hasConfigFiles } from "./config-loader.ts";
14
+ import {
15
+ loadProjectConfig,
16
+ toOrchestratorConfig,
17
+ toTaskRunnerConfig,
18
+ hasConfigFiles,
19
+ } from "./config-loader.ts";
15
20
  export { hasConfigFiles, resolveConfigRoot } from "./config-loader.ts";
16
21
  import type { OrchestratorConfig, TaskRunnerConfig } from "./types.ts";
17
22
  import type { SupervisorConfig } from "./supervisor.ts";
@@ -31,7 +36,10 @@ import { DEFAULT_SUPERVISOR_CONFIG } from "./supervisor.ts";
31
36
  *
32
37
  * Returns the legacy `OrchestratorConfig` (snake_case) shape.
33
38
  */
34
- export function loadOrchestratorConfig(cwd: string, pointerConfigRoot?: string): OrchestratorConfig {
39
+ export function loadOrchestratorConfig(
40
+ cwd: string,
41
+ pointerConfigRoot?: string,
42
+ ): OrchestratorConfig {
35
43
  const unified = loadProjectConfig(cwd, pointerConfigRoot);
36
44
  return toOrchestratorConfig(unified);
37
45
  }
@@ -15,7 +15,15 @@ import { join } from "path";
15
15
 
16
16
  import { execLog } from "./execution.ts";
17
17
  import { resolveOperatorId } from "./naming.ts";
18
- import type { AllocatedLane, LaneTaskOutcome, OrchBatchRuntimeState, OrchestratorConfig, PersistedTaskRecord, BatchDiagnostics, PersistedTaskExitSummary } from "./types.ts";
18
+ import type {
19
+ AllocatedLane,
20
+ LaneTaskOutcome,
21
+ OrchBatchRuntimeState,
22
+ OrchestratorConfig,
23
+ PersistedTaskRecord,
24
+ BatchDiagnostics,
25
+ PersistedTaskExitSummary,
26
+ } from "./types.ts";
19
27
  import { defaultBatchDiagnostics } from "./types.ts";
20
28
 
21
29
  // ── Types ────────────────────────────────────────────────────────────
@@ -173,7 +181,7 @@ export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticE
173
181
  * Serialize diagnostic events to JSONL format (one JSON object per line).
174
182
  */
175
183
  export function eventsToJsonl(events: DiagnosticEvent[]): string {
176
- return events.map(e => JSON.stringify(e)).join("\n") + "\n";
184
+ return events.map((e) => JSON.stringify(e)).join("\n") + "\n";
177
185
  }
178
186
 
179
187
  // ── Human-Readable Summary ───────────────────────────────────────────
@@ -206,7 +214,10 @@ function formatCost(cost: number): string {
206
214
  /**
207
215
  * Generate a human-readable markdown summary report.
208
216
  */
209
- export function buildMarkdownReport(input: DiagnosticReportInput, events: DiagnosticEvent[]): string {
217
+ export function buildMarkdownReport(
218
+ input: DiagnosticReportInput,
219
+ events: DiagnosticEvent[],
220
+ ): string {
210
221
  const { batchId, phase, mode, startedAt, endedAt, diagnostics } = input;
211
222
  const { succeededTasks, failedTasks, skippedTasks, blockedTasks, totalTasks } = input;
212
223
 
@@ -248,7 +259,7 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno
248
259
  lines.push(`|------|--------|---------------|------|----------|---------|`);
249
260
  for (const evt of events) {
250
261
  lines.push(
251
- `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |`
262
+ `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |`,
252
263
  );
253
264
  }
254
265
  lines.push(``);
@@ -276,8 +287,8 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno
276
287
  } else {
277
288
  for (const repoKey of repoKeys) {
278
289
  const repoEvents = byRepo.get(repoKey)!;
279
- const repoSucceeded = repoEvents.filter(e => e.status === "succeeded").length;
280
- const repoFailed = repoEvents.filter(e => e.status === "failed").length;
290
+ const repoSucceeded = repoEvents.filter((e) => e.status === "succeeded").length;
291
+ const repoFailed = repoEvents.filter((e) => e.status === "failed").length;
281
292
  const repoCost = repoEvents.reduce((sum, e) => sum + e.cost, 0);
282
293
 
283
294
  lines.push(`### ${repoKey}`);
@@ -290,7 +301,7 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno
290
301
  lines.push(`|------|--------|---------------|------|----------|`);
291
302
  for (const evt of repoEvents) {
292
303
  lines.push(
293
- `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |`
304
+ `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |`,
294
305
  );
295
306
  }
296
307
  lines.push(``);
@@ -377,7 +388,10 @@ export function assembleDiagnosticInput(
377
388
  ): DiagnosticReportInput {
378
389
  // Build lookup maps for fast per-task enrichment (mirrors serializeBatchState logic).
379
390
  const laneByTaskId = new Map<string, AllocatedLane>();
380
- const allocatedTaskByTaskId = new Map<string, { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }>();
391
+ const allocatedTaskByTaskId = new Map<
392
+ string,
393
+ { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }
394
+ >();
381
395
  for (const lane of lanes) {
382
396
  for (const allocTask of lane.tasks) {
383
397
  laneByTaskId.set(allocTask.taskId, lane);
@@ -401,48 +415,46 @@ export function assembleDiagnosticInput(
401
415
  }
402
416
 
403
417
  // Build task records sorted by taskId for deterministic output.
404
- const tasks: PersistedTaskRecord[] = [...taskIdSet]
405
- .sort()
406
- .map((taskId): PersistedTaskRecord => {
407
- const lane = laneByTaskId.get(taskId);
408
- const outcome = outcomeByTaskId.get(taskId);
409
- const allocated = allocatedTaskByTaskId.get(taskId);
410
-
411
- const record: PersistedTaskRecord = {
412
- taskId,
413
- laneNumber: lane?.laneNumber ?? 0,
414
- sessionName: outcome?.sessionName || lane?.laneSessionId || "",
415
- status: outcome?.status ?? "pending",
416
- taskFolder: "",
417
- startedAt: outcome?.startTime ?? null,
418
- endedAt: outcome?.endTime ?? null,
419
- doneFileFound: outcome?.doneFileFound ?? false,
420
- exitReason: outcome?.exitReason ?? "",
421
- };
422
-
423
- // Repo attribution from allocated task metadata (workspace mode).
424
- if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
425
- record.repoId = allocated.allocatedTask.task.promptRepoId;
426
- }
427
- if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
428
- record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
429
- }
418
+ const tasks: PersistedTaskRecord[] = [...taskIdSet].sort().map((taskId): PersistedTaskRecord => {
419
+ const lane = laneByTaskId.get(taskId);
420
+ const outcome = outcomeByTaskId.get(taskId);
421
+ const allocated = allocatedTaskByTaskId.get(taskId);
422
+
423
+ const record: PersistedTaskRecord = {
424
+ taskId,
425
+ laneNumber: lane?.laneNumber ?? 0,
426
+ sessionName: outcome?.sessionName || lane?.laneSessionId || "",
427
+ status: outcome?.status ?? "pending",
428
+ taskFolder: "",
429
+ startedAt: outcome?.startTime ?? null,
430
+ endedAt: outcome?.endTime ?? null,
431
+ doneFileFound: outcome?.doneFileFound ?? false,
432
+ exitReason: outcome?.exitReason ?? "",
433
+ };
430
434
 
431
- // Partial progress fields from outcome.
432
- if (outcome?.partialProgressCommits !== undefined) {
433
- record.partialProgressCommits = outcome.partialProgressCommits;
434
- }
435
- if (outcome?.partialProgressBranch !== undefined) {
436
- record.partialProgressBranch = outcome.partialProgressBranch;
437
- }
435
+ // Repo attribution from allocated task metadata (workspace mode).
436
+ if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
437
+ record.repoId = allocated.allocatedTask.task.promptRepoId;
438
+ }
439
+ if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
440
+ record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
441
+ }
438
442
 
439
- // v3: Exit diagnostic from outcome.
440
- if (outcome?.exitDiagnostic !== undefined) {
441
- record.exitDiagnostic = outcome.exitDiagnostic;
442
- }
443
+ // Partial progress fields from outcome.
444
+ if (outcome?.partialProgressCommits !== undefined) {
445
+ record.partialProgressCommits = outcome.partialProgressCommits;
446
+ }
447
+ if (outcome?.partialProgressBranch !== undefined) {
448
+ record.partialProgressBranch = outcome.partialProgressBranch;
449
+ }
443
450
 
444
- return record;
445
- });
451
+ // v3: Exit diagnostic from outcome.
452
+ if (outcome?.exitDiagnostic !== undefined) {
453
+ record.exitDiagnostic = outcome.exitDiagnostic;
454
+ }
455
+
456
+ return record;
457
+ });
446
458
 
447
459
  return {
448
460
  orchConfig,
@@ -249,20 +249,20 @@ export const CONTEXT_OVERFLOW_THRESHOLD_PCT = 90;
249
249
  * @since TP-055
250
250
  */
251
251
  export const MODEL_ACCESS_ERROR_PATTERNS: readonly RegExp[] = [
252
- /\b(?:401|403)\b/, // HTTP auth/forbidden status codes
253
- /\b429\b/, // HTTP rate limit
254
- /model[_ ]not[_ ]found/i, // Model not found
255
- /model[_ ](?:is[_ ])?unavailable/i, // Model unavailable
256
- /model[_ ](?:has[_ ]been[_ ])?deprecated/i, // Model deprecated
252
+ /\b(?:401|403)\b/, // HTTP auth/forbidden status codes
253
+ /\b429\b/, // HTTP rate limit
254
+ /model[_ ]not[_ ]found/i, // Model not found
255
+ /model[_ ](?:is[_ ])?unavailable/i, // Model unavailable
256
+ /model[_ ](?:has[_ ]been[_ ])?deprecated/i, // Model deprecated
257
257
  /api[_ ]key[_ ](?:expired|invalid|revoked)/i, // API key issues
258
- /invalid[_ ]api[_ ]key/i, // Invalid API key (alternate phrasing)
258
+ /invalid[_ ]api[_ ]key/i, // Invalid API key (alternate phrasing)
259
259
  /authentication[_ ](?:failed|error|required)/i, // Auth failures
260
- /authorization[_ ](?:failed|error|denied)/i, // Authz failures
261
- /access[_ ]denied/i, // Generic access denied
262
- /permission[_ ]denied/i, // Permission denied
263
- /quota[_ ]exceeded/i, // Quota exceeded
264
- /rate[_ ]limit/i, // Rate limit (phrase)
265
- /insufficient[_ ]quota/i, // Insufficient quota
260
+ /authorization[_ ](?:failed|error|denied)/i, // Authz failures
261
+ /access[_ ]denied/i, // Generic access denied
262
+ /permission[_ ]denied/i, // Permission denied
263
+ /quota[_ ]exceeded/i, // Quota exceeded
264
+ /rate[_ ]limit/i, // Rate limit (phrase)
265
+ /insufficient[_ ]quota/i, // Insufficient quota
266
266
  ];
267
267
 
268
268
  /**
@@ -277,7 +277,7 @@ export const MODEL_ACCESS_ERROR_PATTERNS: readonly RegExp[] = [
277
277
  */
278
278
  export function isModelAccessError(errorMessage: string): boolean {
279
279
  if (!errorMessage) return false;
280
- return MODEL_ACCESS_ERROR_PATTERNS.some(pattern => pattern.test(errorMessage));
280
+ return MODEL_ACCESS_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
281
281
  }
282
282
 
283
283
  /**
@@ -6,7 +6,16 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "
6
6
  import { join, dirname, basename, resolve } from "path";
7
7
 
8
8
  import { FATAL_DISCOVERY_CODES } from "./types.ts";
9
- import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, SegmentCheckboxGroup, StepSegmentMapping, TaskArea, WorkspaceConfig } from "./types.ts";
9
+ import type {
10
+ DiscoveryError,
11
+ DiscoveryResult,
12
+ ParsedTask,
13
+ PromptSegmentDagMetadata,
14
+ SegmentCheckboxGroup,
15
+ StepSegmentMapping,
16
+ TaskArea,
17
+ WorkspaceConfig,
18
+ } from "./types.ts";
10
19
 
11
20
  // ── PROMPT.md Parsing ────────────────────────────────────────────────
12
21
 
@@ -233,8 +242,7 @@ function parseSegmentDagMetadata(
233
242
  metadata: null,
234
243
  error: {
235
244
  code: "SEGMENT_DAG_INVALID",
236
- message:
237
- `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`,
245
+ message: `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`,
238
246
  taskId,
239
247
  taskPath: promptPath,
240
248
  },
@@ -258,8 +266,7 @@ function parseSegmentDagMetadata(
258
266
  metadata: null,
259
267
  error: {
260
268
  code: "SEGMENT_REPO_UNKNOWN",
261
- message:
262
- `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`,
269
+ message: `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`,
263
270
  taskId,
264
271
  taskPath: promptPath,
265
272
  },
@@ -270,8 +277,7 @@ function parseSegmentDagMetadata(
270
277
  metadata: null,
271
278
  error: {
272
279
  code: "SEGMENT_REPO_UNKNOWN",
273
- message:
274
- `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`,
280
+ message: `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`,
275
281
  taskId,
276
282
  taskPath: promptPath,
277
283
  },
@@ -334,8 +340,7 @@ function parseSegmentDagMetadata(
334
340
  metadata: null,
335
341
  error: {
336
342
  code: "SEGMENT_DAG_INVALID",
337
- message:
338
- `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`,
343
+ message: `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`,
339
344
  taskId,
340
345
  taskPath: promptPath,
341
346
  },
@@ -516,15 +521,15 @@ export function parseStepSegmentMapping(
516
521
  }
517
522
  seenRepoIds.add(seg.repoId);
518
523
 
519
- const nextSegIndex = j + 1 < segmentHeaders.length ? segmentHeaders[j + 1].index : stepContent.length;
524
+ const nextSegIndex =
525
+ j + 1 < segmentHeaders.length ? segmentHeaders[j + 1].index : stepContent.length;
520
526
  const segContent = stepContent.slice(seg.index, nextSegIndex);
521
527
  const checkboxes = extractCheckboxes(segContent);
522
528
 
523
529
  if (checkboxes.length === 0) {
524
530
  warnings.push({
525
531
  code: "SEGMENT_STEP_EMPTY",
526
- message:
527
- `Task ${taskId} Step ${header.stepNumber} has empty segment "${seg.repoId}" with no checkboxes.`,
532
+ message: `Task ${taskId} Step ${header.stepNumber} has empty segment "${seg.repoId}" with no checkboxes.`,
528
533
  taskId,
529
534
  });
530
535
  }
@@ -650,9 +655,7 @@ export function parsePromptForOrchestrator(
650
655
 
651
656
  // ── Extract dependencies ─────────────────────────────────────
652
657
  const dependencies: string[] = [];
653
- const depSectionMatch = content.match(
654
- /^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
655
- );
658
+ const depSectionMatch = content.match(/^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m);
656
659
 
657
660
  if (depSectionMatch) {
658
661
  const depBody = depSectionMatch[1].trim();
@@ -669,9 +672,7 @@ export function parsePromptForOrchestrator(
669
672
  }
670
673
 
671
674
  // Pattern 2: Bullet list "- COMP-005 ...", "- **time-off/TO-014** ..."
672
- const bulletMatches = depBody.matchAll(
673
- /^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim,
674
- );
675
+ const bulletMatches = depBody.matchAll(/^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim);
675
676
  for (const m of bulletMatches) {
676
677
  const dep = normalizeDependencyReference(m[1]);
677
678
  if (!dependencies.includes(dep)) dependencies.push(dep);
@@ -709,15 +710,13 @@ export function parsePromptForOrchestrator(
709
710
  if (afterHeader !== -1) {
710
711
  const rest = content.slice(afterHeader + 1);
711
712
  const nextSectionMatch = rest.search(/^##\s|^---/m);
712
- execTargetSectionBody = nextSectionMatch !== -1
713
- ? rest.slice(0, nextSectionMatch)
714
- : rest;
713
+ execTargetSectionBody = nextSectionMatch !== -1 ? rest.slice(0, nextSectionMatch) : rest;
715
714
  }
716
715
  }
717
716
  if (execTargetSectionBody !== null) {
718
717
  // Match "Repo: api" or "**Repo:** api" or "Workspace: api" with whitespace
719
718
  const repoLineMatch = execTargetSectionBody.match(
720
- /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/mi,
719
+ /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/im,
721
720
  );
722
721
  if (repoLineMatch) {
723
722
  const candidate = repoLineMatch[1].trim().toLowerCase();
@@ -729,9 +728,7 @@ export function parsePromptForOrchestrator(
729
728
 
730
729
  // Priority 2 (fallback): Inline "**Repo:** <id>" or "**Workspace:** <id>" anywhere in content
731
730
  if (!promptRepoId) {
732
- const inlineRepoMatch = content.match(
733
- /^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m,
734
- );
731
+ const inlineRepoMatch = content.match(/^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m);
735
732
  if (inlineRepoMatch) {
736
733
  const candidate = inlineRepoMatch[1].trim().toLowerCase();
737
734
  if (REPO_ID_PATTERN.test(candidate)) {
@@ -742,9 +739,7 @@ export function parsePromptForOrchestrator(
742
739
 
743
740
  // ── Extract file scope ───────────────────────────────────────
744
741
  const fileScope: string[] = [];
745
- const fileScopeMatch = content.match(
746
- /^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
747
- );
742
+ const fileScopeMatch = content.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m);
748
743
 
749
744
  if (fileScopeMatch) {
750
745
  const scopeBody = fileScopeMatch[1].trim();
@@ -812,9 +807,43 @@ export function parsePromptForOrchestrator(
812
807
  };
813
808
  }
814
809
 
815
-
816
810
  // ── Area Scanning ────────────────────────────────────────────────────
817
811
 
812
+ /**
813
+ * TP-196 / #462 — Discovery safeguard for `.DONE` authority drift.
814
+ *
815
+ * Discovery has no access to persisted segment state, so it cannot make a
816
+ * hard `.DONE` vs. segment-frontier authority decision (that lives in the
817
+ * monitor/resume guards). What it CAN do cheaply is detect the most common
818
+ * symptom of a stale or premature `.DONE`: a `.DONE` file exists alongside
819
+ * a STATUS.md that still has unchecked checkboxes. When that pattern is
820
+ * found, emit a one-line `console.warn` so operators see the inconsistency
821
+ * during scan. Behaviour of `scanAreaForTasks` is unchanged — the task is
822
+ * still skipped — this is a doctor-style warning only.
823
+ *
824
+ * Returns `true` when the safeguard issued a warning (used by tests).
825
+ */
826
+ export function checkDoneAuthoritySafeguard(
827
+ taskFolder: string,
828
+ logger: (msg: string) => void = console.warn,
829
+ ): boolean {
830
+ const statusPath = join(taskFolder, "STATUS.md");
831
+ if (!existsSync(statusPath)) return false;
832
+ let content: string;
833
+ try {
834
+ content = readFileSync(statusPath, "utf-8");
835
+ } catch {
836
+ return false;
837
+ }
838
+ // Look for any unchecked checkbox `- [ ]` on its own line.
839
+ const hasUnchecked = /^\s*-\s*\[\s\]\s+/m.test(content);
840
+ if (!hasUnchecked) return false;
841
+ logger(
842
+ `[discovery] WARN: .DONE present in ${taskFolder} but STATUS.md contains unchecked checkboxes — possible stale/premature .DONE (#462 safeguard).`,
843
+ );
844
+ return true;
845
+ }
846
+
818
847
  /**
819
848
  * Scan an area path for pending tasks.
820
849
  *
@@ -864,8 +893,14 @@ export function scanAreaForTasks(
864
893
  continue;
865
894
  }
866
895
 
867
- // Skip if .DONE exists (already complete)
868
- if (existsSync(join(entryPath, ".DONE"))) continue;
896
+ // Skip if .DONE exists (already complete).
897
+ // TP-196 / #462: doctor-style safeguard — if .DONE coexists with
898
+ // unchecked checkboxes in STATUS.md, warn so operators can investigate
899
+ // before the task is silently treated as complete.
900
+ if (existsSync(join(entryPath, ".DONE"))) {
901
+ checkDoneAuthoritySafeguard(entryPath);
902
+ continue;
903
+ }
869
904
 
870
905
  // Skip if no PROMPT.md
871
906
  const promptPath = join(entryPath, "PROMPT.md");
@@ -887,7 +922,6 @@ export function scanAreaForTasks(
887
922
  return { tasks, errors };
888
923
  }
889
924
 
890
-
891
925
  // ── Completed Task Set ───────────────────────────────────────────────
892
926
 
893
927
  /**
@@ -957,7 +991,6 @@ export function buildCompletedTaskSet(areaPaths: string[]): Set<string> {
957
991
  return completed;
958
992
  }
959
993
 
960
-
961
994
  // ── Argument Resolution ──────────────────────────────────────────────
962
995
 
963
996
  /**
@@ -995,10 +1028,7 @@ export function resolveArguments(
995
1028
  if (!areaScanPaths.includes(fullPath)) {
996
1029
  areaScanPaths.push(fullPath);
997
1030
  }
998
- } else if (
999
- token.endsWith("PROMPT.md") &&
1000
- existsSync(resolve(cwd, token))
1001
- ) {
1031
+ } else if (token.endsWith("PROMPT.md") && existsSync(resolve(cwd, token))) {
1002
1032
  // Single PROMPT.md file
1003
1033
  directTaskFolders.push(resolve(cwd, dirname(token)));
1004
1034
  } else if (existsSync(resolve(cwd, token))) {
@@ -1129,7 +1159,6 @@ export function applyDependenciesFromCache(
1129
1159
  return { applied };
1130
1160
  }
1131
1161
 
1132
-
1133
1162
  // ── Task Registry ────────────────────────────────────────────────────
1134
1163
 
1135
1164
  /**
@@ -1238,7 +1267,6 @@ export function buildTaskRegistry(
1238
1267
  return { pending, completed, errors };
1239
1268
  }
1240
1269
 
1241
-
1242
1270
  // ── Cross-Area Dependency Resolution ─────────────────────────────────
1243
1271
 
1244
1272
  /** Candidate match for a dependency reference found in task areas. */
@@ -1407,7 +1435,6 @@ export function resolveDependencies(
1407
1435
  return errors;
1408
1436
  }
1409
1437
 
1410
-
1411
1438
  // ── Task-to-Repo Routing ─────────────────────────────────────────────
1412
1439
 
1413
1440
  /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */
@@ -1440,7 +1467,9 @@ export function resolveTaskRouting(
1440
1467
  for (const task of discovery.pending.values()) {
1441
1468
  // ── Explicit segment DAG repo validation (workspace IDs) ─
1442
1469
  if (task.explicitSegmentDag) {
1443
- const unknownRepos = task.explicitSegmentDag.repoIds.filter((repoId) => !validRepoIds.has(repoId));
1470
+ const unknownRepos = task.explicitSegmentDag.repoIds.filter(
1471
+ (repoId) => !validRepoIds.has(repoId),
1472
+ );
1444
1473
  if (unknownRepos.length > 0) {
1445
1474
  errors.push({
1446
1475
  code: "SEGMENT_REPO_UNKNOWN",
@@ -1577,9 +1606,8 @@ export function resolveTaskRouting(
1577
1606
  if (!validRepoIds.has(seg.repoId)) {
1578
1607
  const knownRepos = [...validRepoIds.keys()];
1579
1608
  const suggestions = suggestRepoMatches(seg.repoId, knownRepos);
1580
- const suggestionHint = suggestions.length > 0
1581
- ? ` Did you mean: ${suggestions.join(", ")}?`
1582
- : "";
1609
+ const suggestionHint =
1610
+ suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
1583
1611
  errors.push({
1584
1612
  code: "SEGMENT_STEP_REPO_INVALID",
1585
1613
  message:
@@ -1599,7 +1627,6 @@ export function resolveTaskRouting(
1599
1627
  return errors;
1600
1628
  }
1601
1629
 
1602
-
1603
1630
  // ── Discovery Pipeline (Public) ──────────────────────────────────────
1604
1631
 
1605
1632
  /**
@@ -1721,7 +1748,7 @@ export function runDiscovery(
1721
1748
  for (const task of discovery.pending.values()) {
1722
1749
  if (!task.stepSegmentMap) continue;
1723
1750
  for (const step of task.stepSegmentMap) {
1724
- const stepRepoIds = step.segments.map(s => s.repoId);
1751
+ const stepRepoIds = step.segments.map((s) => s.repoId);
1725
1752
  const seen = new Set<string>();
1726
1753
  for (const rid of stepRepoIds) {
1727
1754
  if (seen.has(rid)) {
@@ -1765,26 +1792,15 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
1765
1792
  }
1766
1793
 
1767
1794
  lines.push("Pending Tasks:");
1768
- const sortedAreas = [...byArea.entries()].sort((a, b) =>
1769
- a[0].localeCompare(b[0]),
1770
- );
1795
+ const sortedAreas = [...byArea.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1771
1796
  for (const [area, tasks] of sortedAreas) {
1772
1797
  lines.push(` ${area}:`);
1773
- const sortedTasks = [...tasks].sort((a, b) =>
1774
- a.taskId.localeCompare(b.taskId),
1775
- );
1798
+ const sortedTasks = [...tasks].sort((a, b) => a.taskId.localeCompare(b.taskId));
1776
1799
  for (const task of sortedTasks) {
1777
1800
  const deps =
1778
- task.dependencies.length > 0
1779
- ? ` → depends on: ${task.dependencies.join(", ")}`
1780
- : "";
1781
- const repo =
1782
- task.resolvedRepoId
1783
- ? ` → repo: ${task.resolvedRepoId}`
1784
- : "";
1785
- lines.push(
1786
- ` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`,
1787
- );
1801
+ task.dependencies.length > 0 ? ` → depends on: ${task.dependencies.join(", ")}` : "";
1802
+ const repo = task.resolvedRepoId ? ` → repo: ${task.resolvedRepoId}` : "";
1803
+ lines.push(` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`);
1788
1804
  }
1789
1805
  }
1790
1806
  lines.push("");
@@ -1815,4 +1831,3 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
1815
1831
 
1816
1832
  return lines.join("\n");
1817
1833
  }
1818
-