pi-crew 0.9.5 → 0.9.8

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 (37) hide show
  1. package/CHANGELOG.md +556 -0
  2. package/README.md +10 -3
  3. package/docs/HARNESS_BACKLOG.md +51 -3
  4. package/docs/dynamic-workflows.md +315 -2
  5. package/docs/fix-plan-disabletools-exit-null.md +219 -0
  6. package/docs/troubleshooting.md +76 -0
  7. package/package.json +10 -3
  8. package/src/config/defaults.ts +8 -4
  9. package/src/extension/team-tool/doctor.ts +14 -0
  10. package/src/extension/team-tool/run.ts +2 -0
  11. package/src/runtime/background-runner.ts +1 -1
  12. package/src/runtime/capability-inventory.ts +20 -1
  13. package/src/runtime/child-pi.ts +109 -11
  14. package/src/runtime/deterministic-ast.ts +161 -0
  15. package/src/runtime/dwf-state-store.ts +97 -0
  16. package/src/runtime/dynamic-workflow-context.ts +381 -7
  17. package/src/runtime/dynamic-workflow-runner.ts +93 -2
  18. package/src/runtime/pi-args.ts +11 -0
  19. package/src/runtime/result-extractor.ts +72 -7
  20. package/src/runtime/task-output-context.ts +25 -9
  21. package/src/runtime/team-runner.ts +8 -3
  22. package/src/runtime/zombie-scanner.ts +297 -0
  23. package/src/schema/team-tool-schema.ts +28 -0
  24. package/src/skills/discover-skills.ts +61 -8
  25. package/src/skills/validate.ts +267 -0
  26. package/src/state/contracts.ts +1 -0
  27. package/src/state/state-store.ts +3 -0
  28. package/src/state/types.ts +9 -0
  29. package/src/ui/dashboard-panes/progress-pane.ts +5 -0
  30. package/src/ui/dwf-phase-display.ts +151 -0
  31. package/src/ui/keybinding-map.ts +128 -41
  32. package/src/ui/run-event-bus.ts +83 -0
  33. package/src/ui/run-snapshot-cache.ts +4 -0
  34. package/src/ui/snapshot-types.ts +3 -0
  35. package/src/workflows/workflow-config.ts +3 -0
  36. package/src/worktree/worktree-manager.ts +94 -0
  37. package/types/dwf.d.ts +187 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * SKILL.md frontmatter validation (L3 of deer-flow→pi-crew plan).
3
+ *
4
+ * Parses a SKILL.md's YAML frontmatter and validates it against the
5
+ * `ALLOWED_SKILL_PROPS` whitelist using HYBRID policy:
6
+ * - HARD errors (missing/malformed `name`/`description`, type mismatches,
7
+ * read/parse failures): EXCLUDE the skill from `discoverSkills()`.
8
+ * - SOFT warnings (unknown props, missing `name` derived from directory):
9
+ * KEEP the skill; surface via `getLastDiscoveryDiagnostics()`.
10
+ *
11
+ * YAML parsing uses the `yaml` package (^2.9.0). It is now a direct dep;
12
+ * before L3 it was only transitively available through
13
+ * `@earendil-works/pi-coding-agent`. Adding it as a direct dep is justified by:
14
+ * - Replaces a fragile line-prefix parser that broke on multi-line folded
15
+ * scalars (`description: >`), quoted strings, and nested YAML.
16
+ * - Standard lib (eemeli/yaml), MIT, actively maintained.
17
+ * - Already in the lockfile at the same version → zero install cost.
18
+ * - Frontmatter is small and well-formed; YAML parsing cost is negligible.
19
+ *
20
+ * The validator runs once per skill at discovery. Discovery is already cached
21
+ * (`CACHE_TTL_MS = 30_000` in discover-skills.ts) so the validation cost is
22
+ * bounded regardless of how many skills exist.
23
+ */
24
+
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import yaml from "yaml";
28
+
29
+ /**
30
+ * Properties allowed in SKILL.md frontmatter.
31
+ *
32
+ * Why this list:
33
+ * - `name`, `description` are the contract used by pi-crew's prompt
34
+ * rendering (see src/runtime/skill-instructions.ts) and capability
35
+ * inventory. Both are HARD-required.
36
+ * - `license`, `allowed-tools`, `compatibility`, `version`, `author` are
37
+ * common metadata; we surface but don't enforce content beyond type.
38
+ * - `metadata` is a free-form key/value bag (mirrors deer-flow `validation.py`).
39
+ *
40
+ * Unknown props (e.g. bundled skills' `origin`, `triggers`) are SOFT-warned,
41
+ * not rejected — see HYBRID policy in the module docstring.
42
+ */
43
+ export const ALLOWED_SKILL_PROPS = new Set<string>([
44
+ "name",
45
+ "description",
46
+ "license",
47
+ "allowed-tools",
48
+ "metadata",
49
+ "compatibility",
50
+ "version",
51
+ "author",
52
+ ]);
53
+
54
+ /** Hyphen-case name regex (Anthropic Agent Skills spec compatible). */
55
+ const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
56
+ const NAME_MAX_LEN = 64;
57
+ const DESCRIPTION_MAX_LEN = 1024;
58
+ const VERSION_REGEX = /^\d+\.\d+(\.\d+)?(-[A-Za-z0-9.-]+)?$/;
59
+
60
+ /**
61
+ * Structured error so callers (capability inventory, logs) can present
62
+ * actionable diagnostics instead of silently dropping malformed skills.
63
+ */
64
+ export interface SkillValidationError {
65
+ /** Absolute path to the skill directory. */
66
+ path: string;
67
+ /** Field name (e.g. "name", "description", "<unknown-prop>") or "frontmatter" for parse errors. */
68
+ field: string;
69
+ /** Human-readable reason. Safe to surface in capability listings. */
70
+ reason: string;
71
+ /** Severity — "error" excludes the skill; "warn" keeps it. */
72
+ severity: "error" | "warn";
73
+ }
74
+
75
+ /**
76
+ * Validated manifest exposes the parsed frontmatter fields in a typed shape.
77
+ * Only present for valid skills; undefined for invalid ones.
78
+ */
79
+ export interface ValidatedSkillManifest {
80
+ name: string;
81
+ description: string;
82
+ license?: string;
83
+ allowedTools?: string[];
84
+ metadata?: Record<string, unknown>;
85
+ compatibility?: string;
86
+ version?: string;
87
+ author?: string;
88
+ }
89
+
90
+ export interface ValidationResult {
91
+ ok: boolean;
92
+ errors: SkillValidationError[];
93
+ manifest?: ValidatedSkillManifest;
94
+ }
95
+
96
+ /**
97
+ * Parse YAML frontmatter from SKILL.md content.
98
+ *
99
+ * Returns an empty object when there is no frontmatter block. Parsing errors
100
+ * surface as `{ ok: false }` rather than throwing — discovery must remain
101
+ * exception-safe.
102
+ */
103
+ export function parseSkillFrontmatter(
104
+ content: string,
105
+ ): { ok: true; data: Record<string, unknown> } | { ok: false; error: string } {
106
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
107
+ if (!match) return { ok: true, data: {} };
108
+ try {
109
+ const parsed = yaml.parse(match[1]);
110
+ if (parsed === null || parsed === undefined) return { ok: true, data: {} };
111
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
112
+ return { ok: false, error: "Frontmatter must be a YAML mapping, not a scalar or list." };
113
+ }
114
+ return { ok: true, data: parsed as Record<string, unknown> };
115
+ } catch (e) {
116
+ return { ok: false, error: `YAML parse error: ${(e as Error).message}` };
117
+ }
118
+ }
119
+
120
+ function hard(path: string, field: string, reason: string): SkillValidationError {
121
+ return { path, field, reason, severity: "error" };
122
+ }
123
+
124
+ function warn(path: string, field: string, reason: string): SkillValidationError {
125
+ return { path, field, reason, severity: "warn" };
126
+ }
127
+
128
+ /**
129
+ * Validate a single skill's frontmatter.
130
+ *
131
+ * @param skillDir Absolute path to the skill directory (must contain SKILL.md).
132
+ * @returns ValidationResult — `ok` is true when there are no HARD errors.
133
+ * `errors[]` always lists ALL violations (HARD + SOFT).
134
+ *
135
+ * Back-compat: when `name` is missing from frontmatter, the validator
136
+ * DERIVES it from the directory name and emits a SOFT warning. Bundled
137
+ * pi-crew skills always set `name` explicitly, so the warning is informational.
138
+ */
139
+ export function validateSkillFrontmatter(skillDir: string): ValidationResult {
140
+ const errors: SkillValidationError[] = [];
141
+ const skillMdPath = path.join(skillDir, "SKILL.md");
142
+ const derivedName = path.basename(skillDir);
143
+
144
+ let content: string;
145
+ try {
146
+ content = fs.readFileSync(skillMdPath, "utf-8");
147
+ } catch (e) {
148
+ errors.push(hard(skillDir, "SKILL.md", `Cannot read SKILL.md: ${(e as Error).message}`));
149
+ return { ok: false, errors };
150
+ }
151
+
152
+ const parsed = parseSkillFrontmatter(content);
153
+ if (!parsed.ok) {
154
+ errors.push(hard(skillDir, "frontmatter", parsed.error));
155
+ return { ok: false, errors };
156
+ }
157
+
158
+ const data = parsed.data;
159
+
160
+ // No frontmatter at all → back-compat: derive everything from the
161
+ // directory. Pre-L3 skills shipped without frontmatter and we don't want
162
+ // to regress them. Surface a SOFT warning so authors know to add YAML.
163
+ if (Object.keys(data).length === 0) {
164
+ errors.push(warn(skillDir, "frontmatter", "No frontmatter block; deriving name from directory and leaving description empty."));
165
+ return {
166
+ ok: true,
167
+ errors,
168
+ manifest: { name: derivedName, description: "" },
169
+ };
170
+ }
171
+
172
+ // ── name: HARD if type/length/regex bad; SOFT if missing (derive from dir)
173
+ const nameRaw = data.name;
174
+ let resolvedName = derivedName;
175
+ if (nameRaw === undefined || nameRaw === null) {
176
+ errors.push(warn(skillDir, "name", `Frontmatter 'name' missing; using directory name "${derivedName}" as fallback. Add explicit 'name' to silence this.`));
177
+ } else if (typeof nameRaw !== "string") {
178
+ errors.push(hard(skillDir, "name", `'name' must be a string, got ${typeof nameRaw}.`));
179
+ } else if (nameRaw.length === 0) {
180
+ errors.push(hard(skillDir, "name", "'name' is empty."));
181
+ } else if (nameRaw.length > NAME_MAX_LEN) {
182
+ errors.push(hard(skillDir, "name", `'name' exceeds ${NAME_MAX_LEN} chars (got ${nameRaw.length}).`));
183
+ } else if (!NAME_REGEX.test(nameRaw)) {
184
+ errors.push(hard(skillDir, "name", `'name' must be hyphen-case lowercase (a-z, 0-9, single hyphens); got "${nameRaw}".`));
185
+ } else {
186
+ resolvedName = nameRaw;
187
+ }
188
+
189
+ // ── description: HARD required
190
+ const descRaw = data.description;
191
+ if (descRaw === undefined || descRaw === null) {
192
+ errors.push(hard(skillDir, "description", "Required field 'description' is missing."));
193
+ } else if (typeof descRaw !== "string") {
194
+ errors.push(hard(skillDir, "description", `'description' must be a string, got ${typeof descRaw}.`));
195
+ } else if (descRaw.length > DESCRIPTION_MAX_LEN) {
196
+ errors.push(hard(skillDir, "description", `'description' exceeds ${DESCRIPTION_MAX_LEN} chars (got ${descRaw.length}).`));
197
+ } else if (descRaw.includes("<") || descRaw.includes(">")) {
198
+ errors.push(hard(skillDir, "description", `'description' must not contain '<' or '>' (prompt-safety).`));
199
+ }
200
+
201
+ // ── OPTIONAL: license
202
+ if (data.license !== undefined && typeof data.license !== "string") {
203
+ errors.push(hard(skillDir, "license", `'license' must be a string.`));
204
+ }
205
+
206
+ // ── OPTIONAL: allowed-tools
207
+ if (data["allowed-tools"] !== undefined) {
208
+ const at = data["allowed-tools"];
209
+ if (!Array.isArray(at) || !at.every((x) => typeof x === "string")) {
210
+ errors.push(hard(skillDir, "allowed-tools", `'allowed-tools' must be an array of strings.`));
211
+ }
212
+ }
213
+
214
+ // ── OPTIONAL: metadata
215
+ if (data.metadata !== undefined) {
216
+ const m = data.metadata;
217
+ if (typeof m !== "object" || m === null || Array.isArray(m)) {
218
+ errors.push(hard(skillDir, "metadata", `'metadata' must be an object.`));
219
+ }
220
+ }
221
+
222
+ // ── OPTIONAL: compatibility
223
+ if (data.compatibility !== undefined && typeof data.compatibility !== "string") {
224
+ errors.push(hard(skillDir, "compatibility", `'compatibility' must be a string.`));
225
+ }
226
+
227
+ // ── OPTIONAL: version
228
+ if (data.version !== undefined) {
229
+ if (typeof data.version !== "string" || !VERSION_REGEX.test(data.version)) {
230
+ errors.push(hard(skillDir, "version", `'version' must be a semver string (e.g. "1.2.3" or "1.2.3-beta.1"); got "${String(data.version)}".`));
231
+ }
232
+ }
233
+
234
+ // ── OPTIONAL: author
235
+ if (data.author !== undefined && typeof data.author !== "string") {
236
+ errors.push(hard(skillDir, "author", `'author' must be a string.`));
237
+ }
238
+
239
+ // ── UNKNOWN PROPS: SOFT warn (HYBRID policy)
240
+ for (const key of Object.keys(data)) {
241
+ if (!ALLOWED_SKILL_PROPS.has(key)) {
242
+ errors.push(warn(skillDir, `<unknown-prop:${key}>`, `Unknown property '${key}' is not in the whitelist; keeping for forward-compat.`));
243
+ }
244
+ }
245
+
246
+ // ── Result: ok iff no HARD errors
247
+ const hasHardError = errors.some((e) => e.severity === "error");
248
+ if (!hasHardError) {
249
+ const manifest: ValidatedSkillManifest = {
250
+ name: resolvedName,
251
+ description: typeof data.description === "string" ? data.description : "",
252
+ };
253
+ if (typeof data.license === "string") manifest.license = data.license;
254
+ if (Array.isArray(data["allowed-tools"])) {
255
+ manifest.allowedTools = (data["allowed-tools"] as unknown[]).filter((x) => typeof x === "string") as string[];
256
+ }
257
+ if (typeof data.metadata === "object" && data.metadata !== null && !Array.isArray(data.metadata)) {
258
+ manifest.metadata = data.metadata as Record<string, unknown>;
259
+ }
260
+ if (typeof data.compatibility === "string") manifest.compatibility = data.compatibility;
261
+ if (typeof data.version === "string") manifest.version = data.version;
262
+ if (typeof data.author === "string") manifest.author = data.author;
263
+ return { ok: true, errors, manifest };
264
+ }
265
+
266
+ return { ok: false, errors };
267
+ }
@@ -91,6 +91,7 @@ const TEAM_EVENT_TYPES = [
91
91
  "dwf.phase_completed",
92
92
  "dwf.completed",
93
93
  "dwf.failed",
94
+ "dwf.log",
94
95
  ] as const;
95
96
  export type TeamEventType = typeof TEAM_EVENT_TYPES[number];
96
97
 
@@ -228,6 +228,8 @@ export function createRunManifest(params: {
228
228
  workspaceMode?: "single" | "worktree";
229
229
  ownerSessionId?: string;
230
230
  runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
231
+ /** round-14 P1-5: typed workflow arguments for .dwf.ts scripts (ctx.args<T>()). */
232
+ args?: unknown;
231
233
  }): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
232
234
  const paths = createRunPaths(params.cwd);
233
235
  const now = new Date().toISOString();
@@ -251,6 +253,7 @@ export function createRunManifest(params: {
251
253
  artifacts: [],
252
254
  ...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
253
255
  runKind: params.runKind ?? "team-run",
256
+ ...(params.args !== undefined ? { args: params.args } : {}),
254
257
  };
255
258
  fs.mkdirSync(paths.stateRoot, { recursive: true });
256
259
  fs.mkdirSync(paths.artifactsRoot, { recursive: true });
@@ -116,6 +116,13 @@ export interface WorkerExitStatus {
116
116
  signal?: string;
117
117
  cleanupErrors: string[];
118
118
  finalDrainMs: number;
119
+ /** Phase-0 diagnostic (HB-003a): final-drain race state for the exit-null
120
+ * disableTools bug. Optional + read-only — absent when no drain timer was
121
+ * ever armed. Phase 1 will use `finalDrainArmed` to decide whether a
122
+ * signal-death (exitCode=null) should be treated as a forced final drain. */
123
+ finalDrainArmed?: boolean;
124
+ forcedFinalDrain?: boolean;
125
+ finalDrainFiredMonotonicMs?: number;
119
126
  }
120
127
 
121
128
  export interface OperationTerminalEvidence {
@@ -185,6 +192,8 @@ export interface TeamRunManifest {
185
192
  runConfig?: unknown;
186
193
  /** Background dispatch discriminator. Default "team-run" runs executeTeamRun; "goal-loop" / "dynamic-workflow" dispatch to their respective runners. Absent = "team-run" for backward compatibility. */
187
194
  runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
195
+ /** round-14 P1-5: typed workflow arguments accessible in .dwf.ts scripts via ctx.args<T>(). Any JSON value; default {} when unset. */
196
+ args?: unknown;
188
197
  summary?: string;
189
198
  policyDecisions?: PolicyDecision[];
190
199
  }
@@ -1,5 +1,6 @@
1
1
  import type { RunUiSnapshot } from "../snapshot-types.ts";
2
2
  import { computePhaseProgress, formatPhaseProgressLine } from "../../runtime/phase-progress.ts";
3
+ import { renderDwfPhaseLines } from "../dwf-phase-display.ts";
3
4
 
4
5
  export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[] {
5
6
  if (!snapshot) return ["Progress pane: snapshot unavailable"];
@@ -16,8 +17,12 @@ export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[
16
17
  })
17
18
  : [];
18
19
  const phaseHeader = phaseLines.length > 0 ? [formatPhaseProgressLine(runProgress), ...phaseLines] : [];
20
+ // DWF logical phases (round-15 P1-4): derived from dwf.phase_* events.
21
+ // Null/absent for non-DWF runs → zero visible change.
22
+ const dwfPhaseLines = snapshot.dwfPhaseState ? renderDwfPhaseLines(snapshot.dwfPhaseState) : [];
19
23
  return [
20
24
  `Progress pane: ${progress.completed}/${progress.total} completed · running=${progress.running} queued=${progress.queued} failed=${progress.failed}`,
25
+ ...dwfPhaseLines,
21
26
  ...phaseHeader,
22
27
  ...cancellationLine,
23
28
  ...groupJoinLines,
@@ -0,0 +1,151 @@
1
+ /**
2
+ * DWF phase display — pure functions for extracting DWF phase state from the
3
+ * run's recent event window and rendering phase markers (▶/✓/⏸) in the
4
+ * progress pane.
5
+ *
6
+ * round-15 (P1-4). These functions are side-effect free and perform no I/O;
7
+ * they derive phase state entirely from the `recentEvents` slice already
8
+ * tailed by `run-snapshot-cache.ts`. Non-DWF runs (no `dwf.phase_*` events)
9
+ * yield `null`, so the progress pane stays unchanged for them.
10
+ */
11
+ import type { TeamEvent } from "../state/event-log.ts";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type DwfPhaseStatus = "running" | "completed" | "pending";
18
+
19
+ export interface DwfPhaseEntry {
20
+ /** Phase title as passed to `ctx.phase(title)`. */
21
+ name: string;
22
+ /** Derived lifecycle status for display. */
23
+ status: DwfPhaseStatus;
24
+ }
25
+
26
+ export interface DwfPhaseState {
27
+ /** Ordered list of phases seen in the event window (first-seen order). */
28
+ phases: DwfPhaseEntry[];
29
+ /** Name of the currently running phase, or null if all are completed. */
30
+ currentPhase: string | null;
31
+ }
32
+
33
+ export interface RenderDwfPhaseOptions {
34
+ /** When true, render ASCII fallback markers instead of Unicode glyphs. */
35
+ ascii?: boolean;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Markers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ // Unicode markers — consistent with the ▸/● glyphs already used in the dashboard.
43
+ const MARKER_RUNNING = "▶";
44
+ const MARKER_COMPLETED = "✓";
45
+ const MARKER_PENDING = "⏸";
46
+
47
+ // ASCII fallbacks for terminals that mis-render the Unicode glyphs above.
48
+ const MARKER_RUNNING_ASCII = "[>]";
49
+ const MARKER_COMPLETED_ASCII = "[v]";
50
+ const MARKER_PENDING_ASCII = "[ ]";
51
+
52
+ const DWF_PHASE_HEADER = " ── DWF Phases ──";
53
+
54
+ function markerFor(status: DwfPhaseStatus, ascii: boolean): string {
55
+ if (ascii) {
56
+ if (status === "running") return MARKER_RUNNING_ASCII;
57
+ if (status === "completed") return MARKER_COMPLETED_ASCII;
58
+ return MARKER_PENDING_ASCII;
59
+ }
60
+ if (status === "running") return MARKER_RUNNING;
61
+ if (status === "completed") return MARKER_COMPLETED;
62
+ return MARKER_PENDING;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Extraction
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function phaseNameFrom(event: TeamEvent): string | undefined {
70
+ const phase = event.data?.phase;
71
+ return typeof phase === "string" && phase.length > 0 ? phase : undefined;
72
+ }
73
+
74
+ /**
75
+ * Derive DWF phase state from a (chronological) event window.
76
+ *
77
+ * Returns `null` when the window contains no `dwf.phase_started` /
78
+ * `dwf.phase_completed` events — i.e. a non-DWF run — so callers can short
79
+ * circuit phase rendering entirely.
80
+ *
81
+ * Because the window is bounded, the oldest phase events may have scrolled
82
+ * off. A phase whose `dwf.phase_started` scrolled off but whose
83
+ * `dwf.phase_completed` is still visible is still tracked (as completed). A
84
+ * phase that started but whose completion scrolled off and which is not the
85
+ * current phase is shown as `pending` (indeterminate).
86
+ */
87
+ export function extractDwfPhaseState(events: TeamEvent[]): DwfPhaseState | null {
88
+ const order: string[] = [];
89
+ const seen = new Set<string>();
90
+ const completed = new Set<string>();
91
+ let currentPhase: string | null = null;
92
+
93
+ const remember = (phase: string): void => {
94
+ if (!seen.has(phase)) {
95
+ seen.add(phase);
96
+ order.push(phase);
97
+ }
98
+ };
99
+
100
+ for (const event of events) {
101
+ if (event.type === "dwf.phase_started") {
102
+ const phase = phaseNameFrom(event);
103
+ if (phase === undefined) continue;
104
+ remember(phase);
105
+ // The most recent phase_started marks the running phase.
106
+ currentPhase = phase;
107
+ } else if (event.type === "dwf.phase_completed") {
108
+ const phase = phaseNameFrom(event);
109
+ if (phase === undefined) continue;
110
+ remember(phase);
111
+ completed.add(phase);
112
+ // If the phase just closed was the running one, it is no longer running.
113
+ if (phase === currentPhase) {
114
+ currentPhase = null;
115
+ }
116
+ }
117
+ }
118
+
119
+ if (order.length === 0) return null;
120
+
121
+ const phases: DwfPhaseEntry[] = order.map((name) => {
122
+ if (name === currentPhase) return { name, status: "running" as const };
123
+ if (completed.has(name)) return { name, status: "completed" as const };
124
+ return { name, status: "pending" as const };
125
+ });
126
+
127
+ return { phases, currentPhase };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Rendering
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Render phase marker lines for the progress pane.
136
+ *
137
+ * - One line per phase: ` ▶ Phase: Scan`, ` ✓ Phase: Scan`, ` ⏸ Phase: Review`.
138
+ * - A grouping header is emitted only when more than one phase is present.
139
+ * - When `options.ascii` is true, ASCII fallback markers are used.
140
+ *
141
+ * Always returns a non-empty array (the caller guarantees a non-null state).
142
+ */
143
+ export function renderDwfPhaseLines(state: DwfPhaseState, options?: RenderDwfPhaseOptions): string[] {
144
+ const ascii = options?.ascii === true;
145
+ const lines: string[] = [];
146
+ if (state.phases.length > 1) lines.push(DWF_PHASE_HEADER);
147
+ for (const entry of state.phases) {
148
+ lines.push(` ${markerFor(entry.status, ascii)} Phase: ${entry.name}`);
149
+ }
150
+ return lines;
151
+ }
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Dashboard keybinding map (L2 refactor: data-driven dispatch).
3
+ *
4
+ * Before L2 this module exposed `DASHBOARD_KEYS` (a data table) but dispatched
5
+ * via a 30-line `if (includes(...)) return "..."` chain — adding a key meant
6
+ * editing BOTH the table AND the dispatch, a DRY violation. L2 collapses the
7
+ * dispatch into a single `for (const b of BINDINGS)` loop driven by the
8
+ * `BINDINGS` table below. `DASHBOARD_KEYS` is retained as the raw key data so
9
+ * existing imports and the dead-but-intentional `KEY_RESERVED` set keep working.
10
+ *
11
+ * Recalibration vs. the original L2 plan: the plan also called for an
12
+ * `inTextInput` guard to prevent letter-key leaks into TUI text inputs.
13
+ * Verified during implementation that this is NOT needed — overlays are
14
+ * mutually exclusive and each has its own `handleInput`. `mailbox-compose-overlay.ts:111`
15
+ * captures every single-char key via `appendText(data)` and never delegates to
16
+ * `dashboardActionForKey`, so there is no leak path. Adding the guard would
17
+ * complicate the API (`run-dashboard.ts:485` has no text-input state to pass)
18
+ * for zero benefit. The input-guard half of L2 is therefore intentionally
19
+ * skipped; only the DRY/data-driven dispatch refactor landed.
20
+ *
21
+ * Origin pattern: deer-flow `frontend/src/components/workspace/command-palette.tsx:39-50`
22
+ * drives shortcuts from a single data array consumed by one loop in
23
+ * `use-global-shortcuts.ts:38-61`.
24
+ */
25
+
1
26
  export const DASHBOARD_KEYS = {
2
27
  close: ["q", "\u001b"],
3
28
  select: ["\r", "\n", "s"],
@@ -21,20 +46,23 @@ export const DASHBOARD_KEYS = {
21
46
  notification: { dismissAll: ["H"] },
22
47
  } as const;
23
48
 
24
- /** @internal */
25
- const KEY_RESERVED = new Set<string>([
26
- ...DASHBOARD_KEYS.close,
27
- ...DASHBOARD_KEYS.select,
28
- ...Object.values(DASHBOARD_KEYS.root).flat(),
29
- ...Object.values(DASHBOARD_KEYS.pane).flat(),
30
- ...Object.values(DASHBOARD_KEYS.navigation).flat(),
31
- ...Object.values(DASHBOARD_KEYS.mailbox).flat(),
32
- ...Object.values(DASHBOARD_KEYS.health).flat(),
33
- ...Object.values(DASHBOARD_KEYS.notification).flat(),
34
- ]);
49
+ /**
50
+ * Pane identifiers that can scope a binding. `undefined` means the binding
51
+ * fires in every pane.
52
+ */
53
+ export type ActivePane = "agents" | "progress" | "mailbox" | "output" | "health" | "metrics";
35
54
 
36
- function includes(values: readonly string[], data: string): boolean {
37
- return values.includes(data);
55
+ /**
56
+ * A single keybinding: the keys that trigger it, the action it produces, and
57
+ * an optional pane restriction. The dispatch loop returns the FIRST matching
58
+ * binding, so table ORDER IS SIGNIFICANT and must mirror the old if-chain
59
+ * precedence (pane-specific overrides before their generic competitors).
60
+ */
61
+ export interface KeyBinding {
62
+ readonly keys: readonly string[];
63
+ readonly action: DashboardKeyAction;
64
+ /** When set, the binding only fires when `activePane === pane`. */
65
+ readonly pane?: ActivePane;
38
66
  }
39
67
 
40
68
  export type DashboardKeyAction =
@@ -65,34 +93,93 @@ export type DashboardKeyAction =
65
93
  | "health-diagnostic-export"
66
94
  | "notifications-dismiss";
67
95
 
68
- export function dashboardActionForKey(data: string, activePane?: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics"): DashboardKeyAction | undefined {
69
- if (includes(DASHBOARD_KEYS.close, data)) return "close";
70
- if (activePane === "mailbox" && includes(DASHBOARD_KEYS.mailbox.openDetail, data)) return "mailbox-detail";
71
- if (activePane === "health") {
72
- if (includes(DASHBOARD_KEYS.health.recovery, data)) return "health-recovery";
73
- if (includes(DASHBOARD_KEYS.health.killStale, data)) return "health-kill-stale";
74
- if (includes(DASHBOARD_KEYS.health.diagnosticExport, data)) return "health-diagnostic-export";
96
+ /**
97
+ * The dispatch table. ORDER MATTERS — first match wins.
98
+ *
99
+ * Precedence notes (must match the pre-L2 if-chain exactly):
100
+ * 1. `close` always wins (q / Esc).
101
+ * 2. `mailbox-detail` (\r, \n) is pane-scoped to mailbox and MUST precede
102
+ * `select` (which also binds \r, \n) so Enter opens the detail instead of
103
+ * triggering select while in the mailbox pane.
104
+ * 3. `health-*` are pane-scoped to health.
105
+ * 4. `notifications-dismiss` (H) is global.
106
+ * 5. `select`, then the root actions, pane switches, and navigation.
107
+ *
108
+ * NOTE: mailbox action keys A/N/C/P/X (ack/nudge/compose/preview/ackAll) are
109
+ * intentionally NOT in this table. They live in `DASHBOARD_KEYS.mailbox` for
110
+ * reservation but are handled by the mailbox overlay's own `handleInput`,
111
+ * not by the dashboard dispatch. Adding them here would change behavior.
112
+ */
113
+ const BINDINGS: readonly KeyBinding[] = [
114
+ { keys: DASHBOARD_KEYS.close, action: "close" },
115
+ { keys: DASHBOARD_KEYS.mailbox.openDetail, action: "mailbox-detail", pane: "mailbox" },
116
+ { keys: DASHBOARD_KEYS.health.recovery, action: "health-recovery", pane: "health" },
117
+ { keys: DASHBOARD_KEYS.health.killStale, action: "health-kill-stale", pane: "health" },
118
+ { keys: DASHBOARD_KEYS.health.diagnosticExport, action: "health-diagnostic-export", pane: "health" },
119
+ { keys: DASHBOARD_KEYS.notification.dismissAll, action: "notifications-dismiss" },
120
+ { keys: DASHBOARD_KEYS.select, action: "select" },
121
+ { keys: DASHBOARD_KEYS.root.summary, action: "summary" },
122
+ { keys: DASHBOARD_KEYS.root.artifacts, action: "artifacts" },
123
+ { keys: DASHBOARD_KEYS.root.api, action: "api" },
124
+ { keys: DASHBOARD_KEYS.root.agents, action: "agents" },
125
+ { keys: DASHBOARD_KEYS.root.mailbox, action: "mailbox" },
126
+ { keys: DASHBOARD_KEYS.root.events, action: "events" },
127
+ { keys: DASHBOARD_KEYS.root.output, action: "output" },
128
+ { keys: DASHBOARD_KEYS.root.transcript, action: "transcript" },
129
+ { keys: DASHBOARD_KEYS.root.liveConversation, action: "live-conversation" },
130
+ { keys: DASHBOARD_KEYS.root.reload, action: "reload" },
131
+ { keys: DASHBOARD_KEYS.root.progressToggle, action: "progressToggle" },
132
+ { keys: DASHBOARD_KEYS.pane.agents, action: "pane-agents" },
133
+ { keys: DASHBOARD_KEYS.pane.progress, action: "pane-progress" },
134
+ { keys: DASHBOARD_KEYS.pane.mailbox, action: "pane-mailbox" },
135
+ { keys: DASHBOARD_KEYS.pane.output, action: "pane-output" },
136
+ { keys: DASHBOARD_KEYS.pane.health, action: "pane-health" },
137
+ { keys: DASHBOARD_KEYS.pane.metrics, action: "pane-metrics" },
138
+ { keys: DASHBOARD_KEYS.navigation.up, action: "up" },
139
+ { keys: DASHBOARD_KEYS.navigation.down, action: "down" },
140
+ ];
141
+
142
+ /**
143
+ * Reserved keys — every key the dashboard claims, including mailbox/health
144
+ * action keys that are NOT dispatched here but are handled by their own
145
+ * overlays. Derived from `DASHBOARD_KEYS` (the full key set) rather than from
146
+ * `BINDINGS` (the dispatched subset) so overlay-handled keys stay reserved.
147
+ *
148
+ * @internal Currently unused outside this module but retained to document
149
+ * intent and support future callers that need to know which keys the
150
+ * dashboard ecosystem owns.
151
+ */
152
+ const KEY_RESERVED = new Set<string>([
153
+ ...DASHBOARD_KEYS.close,
154
+ ...DASHBOARD_KEYS.select,
155
+ ...Object.values(DASHBOARD_KEYS.root).flat(),
156
+ ...Object.values(DASHBOARD_KEYS.pane).flat(),
157
+ ...Object.values(DASHBOARD_KEYS.navigation).flat(),
158
+ ...Object.values(DASHBOARD_KEYS.mailbox).flat(),
159
+ ...Object.values(DASHBOARD_KEYS.health).flat(),
160
+ ...Object.values(DASHBOARD_KEYS.notification).flat(),
161
+ ]);
162
+
163
+ export { KEY_RESERVED };
164
+
165
+ /**
166
+ * Resolve a raw input `data` string to a dashboard action.
167
+ *
168
+ * Data-driven dispatch: iterates `BINDINGS` in order and returns the action of
169
+ * the first binding whose `keys` contain `data` and whose optional `pane`
170
+ * restriction matches `activePane`. Behavior is identical to the pre-L2
171
+ * if-chain (verified by `test/unit/keybinding-map.parity.test.ts`).
172
+ *
173
+ * @param data Raw key input (single char or escape sequence).
174
+ * @param activePane Currently focused pane; pane-scoped bindings only fire
175
+ * when this matches. `undefined` disables all pane-scoped
176
+ * bindings (matching the old behavior where omitting the
177
+ * arg skipped the `activePane === ...` branches).
178
+ */
179
+ export function dashboardActionForKey(data: string, activePane?: ActivePane): DashboardKeyAction | undefined {
180
+ for (const binding of BINDINGS) {
181
+ if (binding.pane !== undefined && binding.pane !== activePane) continue;
182
+ if (binding.keys.includes(data)) return binding.action;
75
183
  }
76
- if (includes(DASHBOARD_KEYS.notification.dismissAll, data)) return "notifications-dismiss";
77
- if (includes(DASHBOARD_KEYS.select, data)) return "select";
78
- if (includes(DASHBOARD_KEYS.root.summary, data)) return "summary";
79
- if (includes(DASHBOARD_KEYS.root.artifacts, data)) return "artifacts";
80
- if (includes(DASHBOARD_KEYS.root.api, data)) return "api";
81
- if (includes(DASHBOARD_KEYS.root.agents, data)) return "agents";
82
- if (includes(DASHBOARD_KEYS.root.mailbox, data)) return "mailbox";
83
- if (includes(DASHBOARD_KEYS.root.events, data)) return "events";
84
- if (includes(DASHBOARD_KEYS.root.output, data)) return "output";
85
- if (includes(DASHBOARD_KEYS.root.transcript, data)) return "transcript";
86
- if (includes(DASHBOARD_KEYS.root.liveConversation, data)) return "live-conversation";
87
- if (includes(DASHBOARD_KEYS.root.reload, data)) return "reload";
88
- if (includes(DASHBOARD_KEYS.root.progressToggle, data)) return "progressToggle";
89
- if (includes(DASHBOARD_KEYS.pane.agents, data)) return "pane-agents";
90
- if (includes(DASHBOARD_KEYS.pane.progress, data)) return "pane-progress";
91
- if (includes(DASHBOARD_KEYS.pane.mailbox, data)) return "pane-mailbox";
92
- if (includes(DASHBOARD_KEYS.pane.output, data)) return "pane-output";
93
- if (includes(DASHBOARD_KEYS.pane.health, data)) return "pane-health";
94
- if (includes(DASHBOARD_KEYS.pane.metrics, data)) return "pane-metrics";
95
- if (includes(DASHBOARD_KEYS.navigation.up, data)) return "up";
96
- if (includes(DASHBOARD_KEYS.navigation.down, data)) return "down";
97
184
  return undefined;
98
185
  }