pi-plans 0.1.1 → 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,21 +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[] {
399
- // Expanded detail view only: the count/hint live in the bottom status bar,
400
- // so the panel below the editor never repeats them.
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.
401
466
  const panel = ensurePanelState(execution);
402
467
  if (!panel.expanded) return [""];
403
468
  const lines: string[] = [];
404
- for (const item of execution.items) {
405
- const summary = panel.itemSummaries[item.id]?.summary;
406
- lines.push(...renderItemLines(item, summary, theme, width));
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
+ }
407
484
  }
408
485
  return lines.length ? lines : [""];
409
486
  }
410
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));
519
+ }
520
+ return lines;
521
+ }
522
+
411
523
  interface WidgetThemeSource {
412
524
  theme: ThemeLike | null;
525
+ requestRender: (() => void) | null;
413
526
  }
414
527
 
415
528
  let renderCacheWidth: number | null = null;
@@ -446,7 +559,7 @@ class ExecutionPanelWidget implements WidgetLike {
446
559
  // a full TUI relayout mid-stream). Registration is tied to the host `ctx.ui`
447
560
  // instance so replacement hosts (extension reload, tests) register afresh.
448
561
  let panelRef: { current: ExecutionPanelExecutionLike | null } = { current: null };
449
- const themeSource: WidgetThemeSource = { theme: null };
562
+ const themeSource: WidgetThemeSource = { theme: null, requestRender: null };
450
563
  let registeredUi: unknown = null;
451
564
 
452
565
  export function refreshExecutionPanel(ctx: ExtensionContext, execution: ExecutionPanelExecutionLike | null): void {
@@ -455,30 +568,51 @@ export function refreshExecutionPanel(ctx: ExtensionContext, execution: Executio
455
568
  return;
456
569
  }
457
570
  if (!execution.panel?.expanded) {
458
- // Collapsed: the bottom status bar carries the count; no panel widget.
459
- clearExecutionPanel(ctx);
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
+ )));
460
586
  return;
461
587
  }
462
588
  panelRef.current = execution;
463
589
  invalidateRenderCache(); // next render always reflects the latest state
464
- if (registeredUi === ctx.ui) {
465
- // Same host, same widget slot: nothing to re-register.
466
- 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
+ );
467
604
  }
468
- registeredUi = ctx.ui;
469
- ctx.ui.setWidget(
470
- WIDGET_ID,
471
- (_tui, theme) => {
472
- themeSource.theme = theme as ThemeLike;
473
- return new ExecutionPanelWidget(themeSource);
474
- },
475
- { placement: "belowEditor" },
476
- );
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?.();
477
610
  }
478
611
 
479
612
  export function clearExecutionPanel(ctx: ExtensionContext): void {
480
613
  panelRef.current = null;
481
614
  registeredUi = null;
615
+ themeSource.requestRender = null;
482
616
  invalidateRenderCache();
483
617
  ctx.ui.setWidget(WIDGET_ID, undefined);
484
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