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.
- package/CHANGELOG.md +556 -0
- package/README.md +10 -3
- package/docs/HARNESS_BACKLOG.md +51 -3
- package/docs/dynamic-workflows.md +315 -2
- package/docs/fix-plan-disabletools-exit-null.md +219 -0
- package/docs/troubleshooting.md +76 -0
- package/package.json +10 -3
- package/src/config/defaults.ts +8 -4
- package/src/extension/team-tool/doctor.ts +14 -0
- package/src/extension/team-tool/run.ts +2 -0
- package/src/runtime/background-runner.ts +1 -1
- package/src/runtime/capability-inventory.ts +20 -1
- package/src/runtime/child-pi.ts +109 -11
- package/src/runtime/deterministic-ast.ts +161 -0
- package/src/runtime/dwf-state-store.ts +97 -0
- package/src/runtime/dynamic-workflow-context.ts +381 -7
- package/src/runtime/dynamic-workflow-runner.ts +93 -2
- package/src/runtime/pi-args.ts +11 -0
- package/src/runtime/result-extractor.ts +72 -7
- package/src/runtime/task-output-context.ts +25 -9
- package/src/runtime/team-runner.ts +8 -3
- package/src/runtime/zombie-scanner.ts +297 -0
- package/src/schema/team-tool-schema.ts +28 -0
- package/src/skills/discover-skills.ts +61 -8
- package/src/skills/validate.ts +267 -0
- package/src/state/contracts.ts +1 -0
- package/src/state/state-store.ts +3 -0
- package/src/state/types.ts +9 -0
- package/src/ui/dashboard-panes/progress-pane.ts +5 -0
- package/src/ui/dwf-phase-display.ts +151 -0
- package/src/ui/keybinding-map.ts +128 -41
- package/src/ui/run-event-bus.ts +83 -0
- package/src/ui/run-snapshot-cache.ts +4 -0
- package/src/ui/snapshot-types.ts +3 -0
- package/src/workflows/workflow-config.ts +3 -0
- package/src/worktree/worktree-manager.ts +94 -0
- 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
|
+
}
|
package/src/state/contracts.ts
CHANGED
package/src/state/state-store.ts
CHANGED
|
@@ -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 });
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|