pi-plans 0.1.0 → 0.1.2

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.
@@ -2,7 +2,15 @@ import * as fs from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import type { CheckItem } from "./plan.ts";
5
+ import {
6
+ extractCoverage,
7
+ resolveImplStatuses,
8
+ shortImplDescription,
9
+ type CheckItem,
10
+ type ImplDisplayState,
11
+ type ImplItem,
12
+ type ImplMarkerState,
13
+ } from "./plan.ts";
6
14
 
7
15
  /** SGR runs plus OSC hyperlinks: escaped bytes, no display columns. */
8
16
  const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
@@ -125,6 +133,8 @@ export interface ExecutionPanelState {
125
133
  export interface ExecutionPanelExecutionLike {
126
134
  planPath: string;
127
135
  items: CheckItem[];
136
+ implItems?: ImplItem[];
137
+ implStatus?: Record<string, ImplMarkerState>;
128
138
  panel?: ExecutionPanelState;
129
139
  }
130
140
 
@@ -395,24 +405,124 @@ function renderItemLines(item: CheckItem, summary: ItemDiffSummary | undefined,
395
405
  return lines;
396
406
  }
397
407
 
408
+ function formatElapsed(startedAt: string): string {
409
+ const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
410
+ const h = String(Math.floor(total / 3600)).padStart(2, "0");
411
+ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
412
+ const sec = String(total % 60).padStart(2, "0");
413
+ return `${h}:${m}:${sec}`;
414
+ }
415
+
416
+ function formatToks(tokens: number): string {
417
+ const n = Math.max(0, Math.round(tokens));
418
+ return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
419
+ }
420
+
421
+ /**
422
+ * Single source for the ⌛ progress count (I-based with zero-coverage
423
+ * exclusion, VC fallback) — shared by the footer line and the panel header.
424
+ */
425
+ export function computeExecutionProgress(execution: ExecutionPanelExecutionLike & {
426
+ usage?: { inToks: number; outToks: number };
427
+ implStatus?: Record<string, ImplMarkerState>;
428
+ }): { done: number; total: number } {
429
+ const implItems = execution.implItems ?? [];
430
+ if (implItems.length) {
431
+ const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
432
+ const counted = implItems.filter((impl) =>
433
+ execution.items.some((item) => extractCoverage(item.text).includes(impl.id)),
434
+ );
435
+ if (counted.length > 0) {
436
+ return {
437
+ done: counted.filter((impl) => statuses[impl.id] === "vc-passed").length,
438
+ total: counted.length,
439
+ };
440
+ }
441
+ }
442
+ return {
443
+ done: execution.items.filter((item) => item.done).length,
444
+ total: execution.items.length,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * The `⌛ plans x/y: spent ...` line. Plain text — callers apply theme color.
450
+ * Shared by the footer line (collapsed) and the panel header (expanded).
451
+ */
452
+ export function formatExecutionStatusLine(
453
+ execution: ExecutionPanelExecutionLike & {
454
+ startedAt: string;
455
+ usage: { inToks: number; outToks: number };
456
+ implStatus?: Record<string, ImplMarkerState>;
457
+ },
458
+ ): string {
459
+ const progress = computeExecutionProgress(execution);
460
+ return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
461
+ }
462
+
398
463
  function renderPanelLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
464
+ // Expanded detail view only: the collapsed count lives in the footer, and
465
+ // the expanded header re-renders the same line above the item list.
399
466
  const panel = ensurePanelState(execution);
400
- const done = execution.items.filter((item) => item.done).length;
401
- const hint = panel.expanded ? "alt+o /plans-list hide" : "alt+o /plans-list details";
402
- const header = truncateAnsi(theme.fg("accent", `📋 plans ${done}/${execution.items.length} · ${hint}`), width);
403
- if (!panel.expanded) {
404
- return [header];
467
+ if (!panel.expanded) return [""];
468
+ const lines: string[] = [];
469
+ const header = formatExecutionStatusLine(
470
+ execution as ExecutionPanelExecutionLike & {
471
+ startedAt: string;
472
+ usage: { inToks: number; outToks: number };
473
+ },
474
+ );
475
+ lines.push(truncateAnsi(theme.fg("accent", header), width));
476
+ if (execution.implItems?.length) {
477
+ lines.push(...renderImplGroupedLines(execution, theme, width));
478
+ } else {
479
+ // Legacy fallback: plans without a parsable Implementation Items section.
480
+ for (const item of execution.items) {
481
+ const summary = panel.itemSummaries[item.id]?.summary;
482
+ lines.push(...renderItemLines(item, summary, theme, width));
483
+ }
405
484
  }
406
- const lines = [header];
407
- for (const item of execution.items) {
408
- const summary = panel.itemSummaries[item.id]?.summary;
409
- lines.push(...renderItemLines(item, summary, theme, width));
485
+ return lines.length ? lines : [""];
486
+ }
487
+
488
+ const IMPL_LABELS: Record<ImplDisplayState, string> = {
489
+ pending: "[Pending]",
490
+ implementing: "[Implementing]",
491
+ implemented: "[Implemented]",
492
+ validating: "[Validating]",
493
+ "vc-passed": "[VC passed]",
494
+ };
495
+
496
+ const IMPL_COLORS: Record<ImplDisplayState, string> = {
497
+ pending: "muted",
498
+ implementing: "accent",
499
+ implemented: "accent",
500
+ validating: "warning",
501
+ "vc-passed": "success",
502
+ };
503
+
504
+ function renderImplGroupedLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
505
+ const implItems = execution.implItems ?? [];
506
+ const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
507
+ const lines: string[] = [];
508
+
509
+ for (const impl of implItems) {
510
+ const status = statuses[impl.id] ?? "pending";
511
+ let line = `${IMPL_LABELS[status]} ${impl.id}: ${shortImplDescription(impl.text)}`;
512
+ if (status === "vc-passed") {
513
+ // Final state: strike through the entire line.
514
+ line = theme.strikethrough(theme.fg(IMPL_COLORS[status], line));
515
+ } else {
516
+ line = theme.fg(IMPL_COLORS[status], line);
517
+ }
518
+ lines.push(truncateAnsi(line, width));
410
519
  }
411
520
  return lines;
412
521
  }
413
522
 
414
523
  interface WidgetThemeSource {
415
524
  theme: ThemeLike | null;
525
+ requestRender: (() => void) | null;
416
526
  }
417
527
 
418
528
  let renderCacheWidth: number | null = null;
@@ -449,7 +559,7 @@ class ExecutionPanelWidget implements WidgetLike {
449
559
  // a full TUI relayout mid-stream). Registration is tied to the host `ctx.ui`
450
560
  // instance so replacement hosts (extension reload, tests) register afresh.
451
561
  let panelRef: { current: ExecutionPanelExecutionLike | null } = { current: null };
452
- const themeSource: WidgetThemeSource = { theme: null };
562
+ const themeSource: WidgetThemeSource = { theme: null, requestRender: null };
453
563
  let registeredUi: unknown = null;
454
564
 
455
565
  export function refreshExecutionPanel(ctx: ExtensionContext, execution: ExecutionPanelExecutionLike | null): void {
@@ -457,26 +567,52 @@ export function refreshExecutionPanel(ctx: ExtensionContext, execution: Executio
457
567
  clearExecutionPanel(ctx);
458
568
  return;
459
569
  }
570
+ if (!execution.panel?.expanded) {
571
+ // Collapsed: footer carries the ⌛ line; no panel widget is needed. Clear
572
+ // only when a live expanded widget exists, so repeated progress refreshes
573
+ // do not re-register or tear down the widget slot.
574
+ const hadWidget = panelRef.current !== null || registeredUi !== null;
575
+ panelRef.current = null;
576
+ registeredUi = null;
577
+ themeSource.requestRender = null;
578
+ invalidateRenderCache();
579
+ if (hadWidget) ctx.ui.setWidget(WIDGET_ID, undefined);
580
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", formatExecutionStatusLine(
581
+ execution as ExecutionPanelExecutionLike & {
582
+ startedAt: string;
583
+ usage: { inToks: number; outToks: number };
584
+ },
585
+ )));
586
+ return;
587
+ }
460
588
  panelRef.current = execution;
461
589
  invalidateRenderCache(); // next render always reflects the latest state
462
- if (registeredUi === ctx.ui) {
463
- // Same host, same widget slot: nothing to re-register.
464
- return;
590
+ const sameHost = registeredUi === ctx.ui;
591
+ if (!sameHost) {
592
+ registeredUi = ctx.ui;
593
+ themeSource.requestRender = null;
594
+ ctx.ui.setWidget(
595
+ WIDGET_ID,
596
+ (tui, theme) => {
597
+ themeSource.theme = theme as ThemeLike;
598
+ const render = (tui as { requestRender?: unknown }).requestRender;
599
+ themeSource.requestRender = typeof render === "function" ? () => (render as () => void)() : null;
600
+ return new ExecutionPanelWidget(themeSource);
601
+ },
602
+ { placement: "belowEditor" },
603
+ );
465
604
  }
466
- registeredUi = ctx.ui;
467
- ctx.ui.setWidget(
468
- WIDGET_ID,
469
- (_tui, theme) => {
470
- themeSource.theme = theme as ThemeLike;
471
- return new ExecutionPanelWidget(themeSource);
472
- },
473
- { placement: "belowEditor" },
474
- );
605
+ // Expanded: the panel renders the ⌛ header itself. Clear the footer copy
606
+ // only when the view is first mounted or the host changes, then request a
607
+ // render for in-place progress updates.
608
+ if (!sameHost) ctx.ui.setStatus("pi-plans", undefined);
609
+ themeSource.requestRender?.();
475
610
  }
476
611
 
477
612
  export function clearExecutionPanel(ctx: ExtensionContext): void {
478
613
  panelRef.current = null;
479
614
  registeredUi = null;
615
+ themeSource.requestRender = null;
480
616
  invalidateRenderCache();
481
617
  ctx.ui.setWidget(WIDGET_ID, undefined);
482
618
  }
package/src/plan.ts CHANGED
@@ -35,6 +35,112 @@ export function scanDoneMarkers(text: string): string[] {
35
35
  return [...text.matchAll(/\[DONE:(VC-\d+)\]/g)].map((match) => match[1]);
36
36
  }
37
37
 
38
+ export type ImplMarkerState = "implemented" | "validating";
39
+
40
+ export interface ImplMarker {
41
+ id: string;
42
+ state: ImplMarkerState;
43
+ }
44
+
45
+ /** Extract every [I-xxx:implemented|validating] marker from an assistant message. */
46
+ export function scanImplMarkers(text: string): ImplMarker[] {
47
+ return [...text.matchAll(/\[(I-\d+):(implemented|validating)\]/g)].map((match) => ({
48
+ id: match[1],
49
+ state: match[2] as ImplMarkerState,
50
+ }));
51
+ }
52
+
53
+ export interface ImplItem {
54
+ id: string;
55
+ text: string;
56
+ }
57
+
58
+ /**
59
+ * Parse the `## Implementation Items` section of a PLAN_vN.md. Strict grammar:
60
+ * top-level `- `I-001`: text` single lines only; multi-line bodies and nested
61
+ * sub-bullets are ignored (text stops at end of the first line).
62
+ */
63
+ export function parseImplItems(planText: string): ImplItem[] {
64
+ const lines = planText.split("\n");
65
+ const headerIndex = lines.findIndex((line) => /^##\s+Implementation Items\s*$/.test(line.trim()));
66
+ if (headerIndex < 0) return [];
67
+ const items: ImplItem[] = [];
68
+ const seen = new Set<string>();
69
+ for (let i = headerIndex + 1; i < lines.length; i++) {
70
+ const line = lines[i];
71
+ if (/^##\s/.test(line.trim())) break; // next section ends the items
72
+ const match = line.match(/^\s*-\s+`(I-\d+)`\s*:\s+(.*)$/);
73
+ if (!match) continue;
74
+ const id = match[1];
75
+ if (seen.has(id)) continue;
76
+ seen.add(id);
77
+ items.push({ id, text: match[2].trim() });
78
+ }
79
+ return items;
80
+ }
81
+
82
+ /**
83
+ * One-sentence description: cut at the first sentence boundary (。/.) or
84
+ * semicolon (;/;), then cap at 80 characters with an ellipsis.
85
+ */
86
+ export function shortImplDescription(text: string): string {
87
+ const clipped = text.split(/[。;;]/)[0] ?? text;
88
+ const sentence = clipped.split(/(?<=[.])\s/)[0] ?? clipped;
89
+ const trimmed = sentence.trim();
90
+ if (trimmed.length <= 80) return trimmed;
91
+ return `${trimmed.slice(0, 79)}…`;
92
+ }
93
+
94
+ /**
95
+ * Extract the covered I-ids from a VC checklist line's coverage clause
96
+ * ("`VC-001` covers `I-002` and `I-003`; pass condition: ..." → ["I-002",
97
+ * "I-003"]). Only references before the first ";" count.
98
+ */
99
+ export function extractCoverage(vcText: string): string[] {
100
+ const clause = vcText.split(";")[0] ?? "";
101
+ return [...clause.matchAll(/\bI-\d+\b/g)].map((match) => match[0]);
102
+ }
103
+
104
+ export type ImplDisplayState = "pending" | "implementing" | "implemented" | "validating" | "vc-passed";
105
+
106
+ /**
107
+ * Resolve the display state for every I-item. Precedence: vc-passed (all
108
+ * covering VCs done, final) > explicit marker (validating / implemented) >
109
+ * derivation (some covering VC done → validating; first I with no covering
110
+ * VC done → implementing; rest → pending).
111
+ */
112
+ export function resolveImplStatuses(
113
+ implItems: ImplItem[],
114
+ items: CheckItem[],
115
+ implStatus: Record<string, ImplMarkerState> | undefined,
116
+ ): Record<string, ImplDisplayState> {
117
+ const result: Record<string, ImplDisplayState> = {};
118
+ let frontierAssigned = false;
119
+ for (const impl of implItems) {
120
+ const coverage = items.filter((item) => extractCoverage(item.text).includes(impl.id));
121
+ if (coverage.length > 0 && coverage.every((item) => item.done)) {
122
+ result[impl.id] = "vc-passed";
123
+ continue;
124
+ }
125
+ const marker = implStatus?.[impl.id];
126
+ if (marker === "validating" || marker === "implemented") {
127
+ result[impl.id] = marker;
128
+ continue;
129
+ }
130
+ if (coverage.some((item) => item.done)) {
131
+ result[impl.id] = "validating";
132
+ continue;
133
+ }
134
+ if (!frontierAssigned) {
135
+ result[impl.id] = "implementing";
136
+ frontierAssigned = true;
137
+ continue;
138
+ }
139
+ result[impl.id] = "pending";
140
+ }
141
+ return result;
142
+ }
143
+
38
144
  export interface PlanVersionFile {
39
145
  path: string;
40
146
  version: number;
package/src/state.ts CHANGED
@@ -34,11 +34,18 @@ export interface RoleConfig {
34
34
  confirmed_at: string | null;
35
35
  }
36
36
 
37
+ export interface ExecutionConfig {
38
+ model_selector: string | null;
39
+ source: SettingSource;
40
+ updated_at: string | null;
41
+ }
42
+
37
43
  export interface PlansConfig {
38
44
  schema: number;
39
45
  language: LanguageConfig;
40
46
  reviewer: RoleConfig;
41
47
  criticizer: RoleConfig;
48
+ execution: ExecutionConfig;
42
49
  artifact_root: string;
43
50
  artifact_root_source: SettingSource;
44
51
  artifact_root_updated_at: string | null;
@@ -69,6 +76,11 @@ export const DEFAULT_CONFIG: PlansConfig = {
69
76
  name_prefix: "pi-plans-criticizer",
70
77
  confirmed_at: null,
71
78
  },
79
+ execution: {
80
+ model_selector: null,
81
+ source: "unset",
82
+ updated_at: null,
83
+ },
72
84
  artifact_root: DEFAULT_ARTIFACT_ROOT,
73
85
  artifact_root_source: "unset",
74
86
  artifact_root_updated_at: null,
@@ -316,6 +328,22 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
316
328
  return { config, stateRoot, notices };
317
329
  }
318
330
 
331
+ export interface SetExecutionModelOptions {
332
+ modelSelector?: string;
333
+ source: "user" | "auto";
334
+ }
335
+
336
+ export function setExecutionModel(workdir: string, options: SetExecutionModelOptions): EnsureResult {
337
+ const { config, stateRoot, notices } = ensureState(workdir);
338
+ config.execution = {
339
+ model_selector: options.modelSelector === undefined || options.modelSelector === "inherit" ? null : options.modelSelector,
340
+ source: options.source,
341
+ updated_at: utcNow(),
342
+ };
343
+ atomicWriteJson(path.join(stateRoot, "config.json"), config);
344
+ return { config, stateRoot, notices };
345
+ }
346
+
319
347
  export interface SetRoleOptions {
320
348
  role: "reviewer" | "criticizer";
321
349
  mode?: string;
@@ -359,6 +387,7 @@ export interface StartRunOptions {
359
387
  topic: string;
360
388
  skill: string;
361
389
  requestText: string;
390
+ onStart?: (run: RunInfo) => void;
362
391
  }
363
392
 
364
393
  export interface StartRunResult {
@@ -408,6 +437,13 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
408
437
  run_dir: runDir,
409
438
  artifact_dir: artifactDir,
410
439
  } satisfies ActiveInfo);
440
+ if (options.onStart) {
441
+ try {
442
+ options.onStart(run);
443
+ } catch {
444
+ /* marker failure is non-fatal: planning start should not abort when the entry appender rejects */
445
+ }
446
+ }
411
447
  return { run, notices };
412
448
  }
413
449