avtc-pi-featyard 1.1.0
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/LICENSE +21 -0
- package/README.md +194 -0
- package/agents/fy-design-reviewer.md +77 -0
- package/agents/fy-feature-verifier.md +24 -0
- package/agents/fy-general-reviewer.md +81 -0
- package/agents/fy-guidelines-reviewer.md +46 -0
- package/agents/fy-implementer.md +20 -0
- package/agents/fy-performance-reviewer.md +46 -0
- package/agents/fy-plan-reviewer.md +74 -0
- package/agents/fy-plan-verifier.md +44 -0
- package/agents/fy-quality-reviewer.md +50 -0
- package/agents/fy-requirements-reviewer.md +46 -0
- package/agents/fy-researcher.md +6 -0
- package/agents/fy-security-reviewer.md +46 -0
- package/agents/fy-task-verifier.md +42 -0
- package/agents/fy-testing-reviewer.md +60 -0
- package/assets/images/kanban-board.png +0 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/assets/images/widget.png +0 -0
- package/docs/CONFIGURATION.md +266 -0
- package/index.ts +8 -0
- package/package.json +85 -0
- package/skills/fy-design/SKILL.md +108 -0
- package/skills/fy-design-review/SKILL.md +45 -0
- package/skills/fy-finish/SKILL.md +28 -0
- package/skills/fy-implement/SKILL.md +36 -0
- package/skills/fy-plan/SKILL.md +77 -0
- package/skills/fy-plan-review/SKILL.md +45 -0
- package/skills/fy-research/SKILL.md +38 -0
- package/skills/fy-review/SKILL.md +88 -0
- package/skills/fy-verify/SKILL.md +23 -0
- package/src/commands/auto-agent-commands.ts +748 -0
- package/src/commands/kanban-commands.ts +278 -0
- package/src/commands/workflow-commands.ts +531 -0
- package/src/compaction/compact-handler.ts +371 -0
- package/src/compaction/compact-message.ts +52 -0
- package/src/compaction/compact-trigger.ts +121 -0
- package/src/compaction/safe-editor-write.ts +18 -0
- package/src/events/agent/agent-end.ts +19 -0
- package/src/events/agent/agent-settled.ts +20 -0
- package/src/events/agent/agent-start.ts +18 -0
- package/src/events/agent/turn-end.ts +20 -0
- package/src/events/agent/turn-start.ts +19 -0
- package/src/events/event-deps.ts +33 -0
- package/src/events/index.ts +91 -0
- package/src/events/input/input.ts +143 -0
- package/src/events/session/context.ts +68 -0
- package/src/events/session/model-select.ts +20 -0
- package/src/events/session/session-compact.ts +20 -0
- package/src/events/session/session-shutdown.ts +26 -0
- package/src/events/session/session-start.ts +45 -0
- package/src/events/session/session-tree.ts +17 -0
- package/src/events/tool/tool-call.ts +58 -0
- package/src/events/tool/tool-result.ts +54 -0
- package/src/git/git-queries.ts +154 -0
- package/src/git/resolve-base-branch.ts +173 -0
- package/src/git/worktrees/worktree-helpers.ts +142 -0
- package/src/git/worktrees/worktree-interception.ts +66 -0
- package/src/git/worktrees/worktree-lifecycle.ts +380 -0
- package/src/guardrails/completion-gating.ts +63 -0
- package/src/guardrails/file-classifier.ts +461 -0
- package/src/guardrails/force-add-guard.ts +312 -0
- package/src/guardrails/guardrail-tracker.ts +73 -0
- package/src/guardrails/guardrail-violations.ts +47 -0
- package/src/guardrails/guardrails-engine.ts +663 -0
- package/src/guardrails/session-guardrails.ts +75 -0
- package/src/guardrails/shell-decompose.ts +285 -0
- package/src/guardrails/tdd-enforcement.ts +122 -0
- package/src/guardrails/test-output.ts +272 -0
- package/src/index.ts +598 -0
- package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
- package/src/integrations/subagent-integration.ts +166 -0
- package/src/integrations/todo-integration.ts +58 -0
- package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
- package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
- package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
- package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
- package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
- package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
- package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
- package/src/kanban/data/kanban-database.ts +851 -0
- package/src/kanban/data/kanban-db-schema.ts +137 -0
- package/src/kanban/data/kanban-detect-project.ts +43 -0
- package/src/kanban/data/kanban-move-feature.ts +43 -0
- package/src/kanban/data/kanban-types.ts +64 -0
- package/src/kanban/ensure-feature.ts +226 -0
- package/src/kanban/kanban-board-ui/app.js +1125 -0
- package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
- package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
- package/src/kanban/kanban-board-ui/index.html +127 -0
- package/src/kanban/kanban-board-ui/style.css +820 -0
- package/src/kanban/kanban-bridge.ts +216 -0
- package/src/kanban/kanban-context.ts +43 -0
- package/src/kanban/kanban-events.ts +34 -0
- package/src/kanban/kanban-generate-title.ts +259 -0
- package/src/kanban/kanban-generate-topic.ts +170 -0
- package/src/kanban/kanban-operations.ts +170 -0
- package/src/kanban/kanban-server.ts +931 -0
- package/src/kanban/kanban-turn-handlers.ts +55 -0
- package/src/kanban/model-capture.ts +34 -0
- package/src/log.ts +47 -0
- package/src/phases/env-sync.ts +64 -0
- package/src/phases/execution-mode.ts +79 -0
- package/src/phases/implement-to-verify.ts +31 -0
- package/src/phases/phase-progression.ts +249 -0
- package/src/phases/phase-transitions.ts +226 -0
- package/src/phases/workflow-router.ts +98 -0
- package/src/prompts/finish-instructions.ts +143 -0
- package/src/prompts/skill-block-builder.ts +73 -0
- package/src/prompts/skill-expansion.ts +262 -0
- package/src/prompts/task-gate-skill.ts +83 -0
- package/src/prompts/template-engine.ts +433 -0
- package/src/prompts/text-blocks.ts +279 -0
- package/src/review/review-context.ts +56 -0
- package/src/review/review-counter.ts +66 -0
- package/src/review/review-empty-loop-tracking.ts +52 -0
- package/src/review/review-loops.ts +217 -0
- package/src/review/review-report.ts +101 -0
- package/src/settings/model-overrides.ts +269 -0
- package/src/settings/settings-schema.ts +458 -0
- package/src/settings/settings-types.ts +81 -0
- package/src/settings/settings-ui.ts +222 -0
- package/src/shared/errors.ts +24 -0
- package/src/shared/global.d.ts +22 -0
- package/src/shared/session-entries.ts +24 -0
- package/src/shared/types.ts +195 -0
- package/src/shared/workflow-refs.ts +253 -0
- package/src/shared/workflow-types.ts +180 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
- package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
- package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
- package/src/state/archive-artifacts.ts +602 -0
- package/src/state/artifact-junction.ts +462 -0
- package/src/state/artifact-paths.ts +230 -0
- package/src/state/feature-activation.ts +239 -0
- package/src/state/feature-management.ts +325 -0
- package/src/state/feature-record-store.ts +39 -0
- package/src/state/feature-session.ts +263 -0
- package/src/state/feature-state.ts +439 -0
- package/src/state/post-turn-dispatch.ts +106 -0
- package/src/state/session-lifecycle.ts +346 -0
- package/src/state/state-persistence.ts +122 -0
- package/src/state/worth-notes.ts +89 -0
- package/src/tools/add-to-backlog.ts +117 -0
- package/src/tools/phase-ready.ts +742 -0
- package/src/tools/task-ready-advance.ts +235 -0
- package/src/tools/text-result.ts +15 -0
- package/src/ui/featyard-widget.ts +250 -0
- package/src/ui/manage-features-dialog.ts +246 -0
- package/src/ui/select-dialog.ts +27 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `task_ready_advance` — native pi tool invoked by the implementer at each
|
|
6
|
+
* plan-task boundary during the implement phase of an active featyard workflow.
|
|
7
|
+
*
|
|
8
|
+
* One tool owns all implement-phase task transitions:
|
|
9
|
+
* - START a task (currentTask == null, nextTask set)
|
|
10
|
+
* - ADVANCE to the next (currentTask set, gates clean/cap/gates-off, nextTask set)
|
|
11
|
+
* - last task → verify (currentTask set, nextTask omitted, todos done)
|
|
12
|
+
*
|
|
13
|
+
* Between START and advance, the tool drives a per-task gate cycle: after a task
|
|
14
|
+
* is implemented, the model calls this tool, and the extension either dispatches
|
|
15
|
+
* a fresh `fy-task-gate` round (verify/review subagents run, model triages,
|
|
16
|
+
* recalls the tool with fixable counts) or advances. The gate cycle is bound by
|
|
17
|
+
* `maxTaskReviewRounds` (stops when clean; no escalation at cap). The cycle is
|
|
18
|
+
* dispatch-driven (mirrors `phase_ready`): the model calls the tool and waits for
|
|
19
|
+
* the result, which tells it to continue working (advance) or end its turn (gate).
|
|
20
|
+
*
|
|
21
|
+
* `state.implement.taskReviewRounds[taskKey]` is the 0-indexed rounds-dispatched
|
|
22
|
+
* counter (sole incrementer = this tool; `resolveReviewLoopIndex` is a pure read
|
|
23
|
+
* that numbers both per-task agents' report files).
|
|
24
|
+
*
|
|
25
|
+
* Gated: blocked outside an active featyard workflow and outside the implement
|
|
26
|
+
* phase (returns a reason instead of executing).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
30
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { NO_COMPACT_CALLBACK, triggerContextCompact } from "../compaction/compact-trigger.js";
|
|
32
|
+
import { areAllTodosDone } from "../integrations/todo-integration.js";
|
|
33
|
+
import { log } from "../log.js";
|
|
34
|
+
import { advanceImplementToVerify } from "../phases/implement-to-verify.js";
|
|
35
|
+
import { toRouteConfig } from "../phases/workflow-router.js";
|
|
36
|
+
import { buildTaskGateSkill, sanitizeSkillText } from "../prompts/task-gate-skill.js";
|
|
37
|
+
import { getSettings } from "../settings/settings-ui.js";
|
|
38
|
+
import {
|
|
39
|
+
expandSkillCommand,
|
|
40
|
+
getGuardrailsRef,
|
|
41
|
+
NO_AGENT_NAME,
|
|
42
|
+
NO_FEATURE_STATE_OVERRIDE,
|
|
43
|
+
} from "../shared/workflow-refs.js";
|
|
44
|
+
import { slugifyTaskDesignation } from "../state/artifact-paths.js";
|
|
45
|
+
import { schedulePostTurnFollowUp } from "../state/post-turn-dispatch.js";
|
|
46
|
+
import { persistState } from "../state/state-persistence.js";
|
|
47
|
+
import { textResult } from "./text-result.js";
|
|
48
|
+
|
|
49
|
+
// --- Schema ---
|
|
50
|
+
|
|
51
|
+
const Schema = Type.Object({
|
|
52
|
+
verifierIssuesFixed: Type.Optional(
|
|
53
|
+
Type.Number({
|
|
54
|
+
description:
|
|
55
|
+
"Fixable issues you fixed this pass from the fy-task-verifier report (exclude false-positives and cannot-fix). Omit on START/entry.",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
reviewerIssuesFixed: Type.Optional(
|
|
59
|
+
Type.Number({
|
|
60
|
+
description:
|
|
61
|
+
"Fixable issues you fixed this pass from the fy-general-reviewer report, plus issues you self-found and fixed (exclude false-positives and cannot-fix). Omit on START/entry.",
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
nextTask: Type.Optional(
|
|
65
|
+
Type.String({
|
|
66
|
+
description:
|
|
67
|
+
"The plan-task to advance to next (<task number + name>), or omit on the last task to finish implementation.",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- Registration ---
|
|
73
|
+
|
|
74
|
+
export function registerTaskReadyAdvance(pi: ExtensionAPI, recoverCompactFailure: () => void): void {
|
|
75
|
+
pi.registerTool({
|
|
76
|
+
name: "task_ready_advance",
|
|
77
|
+
label: "",
|
|
78
|
+
description:
|
|
79
|
+
"Use this tool ONLY when instructed by fy-implement skill. Start a task, advance to the next, or — on the last task, with nextTask omitted — finish implementation. After implementing a task, call it to enter the per-task gate cycle (the extension dispatches the gates or advances). Pass the fixable issues you fixed this pass per gate (verifierIssuesFixed, reviewerIssuesFixed; omit if no gates ran). Track active task from existing task-plan document. Not related to todo tools or items.",
|
|
80
|
+
parameters: Schema,
|
|
81
|
+
|
|
82
|
+
async execute(_id, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
83
|
+
// Entry guard: only meaningful inside an active featyard workflow, implement phase.
|
|
84
|
+
const handler = globalThis.__piWorkflowMonitor?.handler ?? null;
|
|
85
|
+
if (!handler) {
|
|
86
|
+
return textResult("Not available outside featyard implement phase.");
|
|
87
|
+
}
|
|
88
|
+
const ws = handler.getWorkflowState();
|
|
89
|
+
if (ws?.currentPhase !== "implement") {
|
|
90
|
+
return textResult("Not available outside featyard implement phase.");
|
|
91
|
+
}
|
|
92
|
+
const featureState = handler.getActiveFeatureState() ?? null;
|
|
93
|
+
if (!featureState) {
|
|
94
|
+
return textResult("Not available outside featyard implement phase.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const settings = getSettings();
|
|
98
|
+
|
|
99
|
+
// Shared task-entry side-effects (START and task→task advance both enter a new task).
|
|
100
|
+
const enterTask = async (nextTask: string): Promise<void> => {
|
|
101
|
+
featureState.implement.currentTask = nextTask;
|
|
102
|
+
featureState.implement.taskReviewRounds[slugifyTaskDesignation(nextTask)] = 0;
|
|
103
|
+
persistState(pi, handler);
|
|
104
|
+
await triggerContextCompact(
|
|
105
|
+
ctx,
|
|
106
|
+
{
|
|
107
|
+
settingValue: settings.interTaskCompact,
|
|
108
|
+
message: `Next: "${sanitizeSkillText(nextTask)}".`,
|
|
109
|
+
logLabel: "inter-task compact",
|
|
110
|
+
},
|
|
111
|
+
NO_COMPACT_CALLBACK,
|
|
112
|
+
recoverCompactFailure,
|
|
113
|
+
);
|
|
114
|
+
globalThis.__piWorkflowMonitor?.requestWidgetUpdate?.();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const fixedV = params.verifierIssuesFixed ?? 0;
|
|
118
|
+
const fixedR = params.reviewerIssuesFixed ?? 0;
|
|
119
|
+
const cur = featureState.implement.currentTask ?? null;
|
|
120
|
+
|
|
121
|
+
// --- START (no task in progress) ---
|
|
122
|
+
if (cur === null) {
|
|
123
|
+
const nextTask = String(params.nextTask ?? "").trim();
|
|
124
|
+
if (!nextTask) {
|
|
125
|
+
return textResult("Provide nextTask to start a task.");
|
|
126
|
+
}
|
|
127
|
+
log.info(`task_ready_advance: START → ${nextTask}`);
|
|
128
|
+
await enterTask(nextTask);
|
|
129
|
+
return textResult(`Current task: "${sanitizeSkillText(nextTask)}".`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- gate cycle (current task in progress) ---
|
|
133
|
+
const key = slugifyTaskDesignation(cur);
|
|
134
|
+
const round = featureState.implement.taskReviewRounds[key] ?? 0; // 0-indexed; resume-safe (coerce missing entry on resumed tasks)
|
|
135
|
+
const verifyActive = settings.verifyPhases.includes("implement");
|
|
136
|
+
const reviewActive = settings.perTaskReviewMode === "general";
|
|
137
|
+
const max = settings.maxTaskReviewRounds;
|
|
138
|
+
|
|
139
|
+
// Dispatch decision (initialize before either branch — the dispatch block below reads the gate flags).
|
|
140
|
+
let dispatch = false;
|
|
141
|
+
let runVerifier = false;
|
|
142
|
+
let runReviewer = false;
|
|
143
|
+
let nextRound = round;
|
|
144
|
+
|
|
145
|
+
if (round === 0 && (verifyActive || reviewActive)) {
|
|
146
|
+
// ENTRY — gates spawn-active: dispatch round 1 with all active gates.
|
|
147
|
+
dispatch = true;
|
|
148
|
+
nextRound = 1;
|
|
149
|
+
runVerifier = verifyActive;
|
|
150
|
+
runReviewer = reviewActive;
|
|
151
|
+
} else if (
|
|
152
|
+
round >= 1 &&
|
|
153
|
+
((verifyActive && fixedV > 0) || (reviewActive && fixedR > 0)) &&
|
|
154
|
+
round < max // activeness-gated: an inactive gate's count can't drive a round
|
|
155
|
+
) {
|
|
156
|
+
// RELOOP (asymmetric respawn).
|
|
157
|
+
dispatch = true;
|
|
158
|
+
nextRound = round + 1;
|
|
159
|
+
runVerifier = verifyActive && fixedV > 0; // verifier re-runs only after verifier fixes (rigorous/costly)
|
|
160
|
+
runReviewer = reviewActive && (fixedV > 0 || fixedR > 0); // reviewer re-runs after any fix (covers the full diff)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (dispatch) {
|
|
164
|
+
featureState.implement.taskReviewRounds[key] = nextRound;
|
|
165
|
+
persistState(pi, handler);
|
|
166
|
+
log.info(
|
|
167
|
+
`task_ready_advance: dispatch round ${nextRound}/${max} (verify=${runVerifier} review=${runReviewer}) for ${cur}`,
|
|
168
|
+
);
|
|
169
|
+
const skill = buildTaskGateSkill({
|
|
170
|
+
round: nextRound,
|
|
171
|
+
task: cur,
|
|
172
|
+
next: params.nextTask,
|
|
173
|
+
runVerifier,
|
|
174
|
+
runReviewer,
|
|
175
|
+
});
|
|
176
|
+
pi.sendUserMessage(skill, { deliverAs: "steer" });
|
|
177
|
+
return textResult("End your turn, wait for instructions."); // NO compact (the gate skill must reach the agent)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- ADVANCE (clean / cap reached / gates-off) ---
|
|
181
|
+
// nextTask must be a real task name to advance; an empty value is neither a valid
|
|
182
|
+
// advance nor an explicit omission (to finish, omit nextTask entirely).
|
|
183
|
+
const nextTaskRaw = params.nextTask;
|
|
184
|
+
if (nextTaskRaw !== undefined && nextTaskRaw !== null) {
|
|
185
|
+
const nextTask = String(nextTaskRaw).trim();
|
|
186
|
+
if (!nextTask) {
|
|
187
|
+
return textResult("Provide a non-empty nextTask to advance, or omit nextTask to finish the last task.");
|
|
188
|
+
}
|
|
189
|
+
log.info(`task_ready_advance: ADVANCE ${cur} → ${nextTask}`);
|
|
190
|
+
await enterTask(nextTask);
|
|
191
|
+
return textResult(`Current task: "${sanitizeSkillText(nextTask)}", do not end your turn, work on it.`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- LAST → VERIFY (nextTask omitted) ---
|
|
195
|
+
if (!areAllTodosDone()) {
|
|
196
|
+
log.info("[workflow] task_ready_advance (last→verify): todos not all done, staying in implement");
|
|
197
|
+
return textResult("Not all TODO items are complete. Finish every item, then call task_ready_advance again.");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The tool is deps-less (a plain registerTool), so reach the guardrails instance
|
|
201
|
+
// via the module ref. It is wired unconditionally at extension init (same synchronous
|
|
202
|
+
// init that registers this tool) — null here is an init invariant violation, so throw
|
|
203
|
+
// (loud + visible) rather than return a silent model-only message.
|
|
204
|
+
const guardrails = getGuardrailsRef();
|
|
205
|
+
if (!guardrails) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
"task_ready_advance (last→verify): guardrails ref not wired — extension initialization incomplete",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Reset the implement-scoped task pointer, then run the implement→verify transition
|
|
212
|
+
// machinery (which persists state in one write — nothing in the transition reads currentTask).
|
|
213
|
+
featureState.implement.currentTask = null;
|
|
214
|
+
await advanceImplementToVerify(pi, ctx, handler, guardrails, toRouteConfig(settings));
|
|
215
|
+
|
|
216
|
+
const fired = await triggerContextCompact(
|
|
217
|
+
ctx,
|
|
218
|
+
{
|
|
219
|
+
settingValue: settings.interTaskCompact,
|
|
220
|
+
// skillName omitted → the compact handler auto-derives fy-verify (the phase has advanced to verify).
|
|
221
|
+
message: "Implementation complete — advancing to the verify phase.",
|
|
222
|
+
logLabel: "implement→verify compact",
|
|
223
|
+
},
|
|
224
|
+
NO_COMPACT_CALLBACK,
|
|
225
|
+
recoverCompactFailure,
|
|
226
|
+
);
|
|
227
|
+
if (!fired) {
|
|
228
|
+
// Fallback: fire on ANY !fired (busy / mode≠compact / below-threshold) so the verify skill still dispatches.
|
|
229
|
+
// Staged for agent_end delivery (see post-turn-dispatch) so it starts a fresh agent cycle.
|
|
230
|
+
schedulePostTurnFollowUp(expandSkillCommand("/skill:fy-verify", NO_FEATURE_STATE_OVERRIDE, NO_AGENT_NAME));
|
|
231
|
+
}
|
|
232
|
+
return textResult("End your turn, wait for instructions for advancing to the next phase.");
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a minimal tool result carrying only text content.
|
|
8
|
+
*
|
|
9
|
+
* `AgentToolResult<T>` requires a `details` field, even when the tool's
|
|
10
|
+
* `renderResult` only inspects `content`. This helper supplies an unused
|
|
11
|
+
* `details` so call sites stay focused on the text they want to return.
|
|
12
|
+
*/
|
|
13
|
+
export function textResult(text: string): AgentToolResult<unknown> {
|
|
14
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
15
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TUI widget rendering for workflow monitor.
|
|
6
|
+
*
|
|
7
|
+
* Renders a single merged line pulling together the phase progression bar,
|
|
8
|
+
* the active phase (+ review-loop counts), auto-agent status, and feature
|
|
9
|
+
* identity. The feature slug is elastically trimmed (middle elision) to fit
|
|
10
|
+
* the terminal width. Per-task progress is owned by the standalone TODO
|
|
11
|
+
* widget (which auto-hides when empty).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import { isPhaseDone, type Phase, WORKFLOW_PHASES } from "../phases/phase-progression.js";
|
|
17
|
+
import type { FeatureSession } from "../state/feature-session.js";
|
|
18
|
+
import type { FeatureState } from "../state/feature-state.js";
|
|
19
|
+
|
|
20
|
+
/** Pass as `featureState` when no pre-loaded state is available (will load from disk). */
|
|
21
|
+
export const NO_FEATURE_STATE: FeatureState | null = null;
|
|
22
|
+
|
|
23
|
+
/** Pass to ctx.ui.setWidget() as the content argument to clear (remove) the widget. */
|
|
24
|
+
export const NO_WIDGET_CONTENT: Parameters<ExtensionContext["ui"]["setWidget"]>[1] = undefined;
|
|
25
|
+
|
|
26
|
+
/** Pass to ctx.ui.setStatus() as the content argument to clear (remove) a status entry. */
|
|
27
|
+
export const NO_STATUS: Parameters<ExtensionContext["ui"]["setStatus"]>[1] = undefined;
|
|
28
|
+
|
|
29
|
+
// --- Terminal width + elastic line fitting ---
|
|
30
|
+
|
|
31
|
+
const TERM_DEFAULT = 80;
|
|
32
|
+
const SEP = " · ";
|
|
33
|
+
|
|
34
|
+
/** Usable terminal width (2-char safety margin). */
|
|
35
|
+
function getTermWidth(): number {
|
|
36
|
+
return (process.stdout.columns ?? TERM_DEFAULT) - 2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A renderable line segment. Fixed segments are pre-themed strings. Elastic
|
|
41
|
+
* segments keep a fixed `prefix` and a `tail` that shrinks (with middle
|
|
42
|
+
* elision) when the assembled line overflows the terminal width. An optional
|
|
43
|
+
* `sepBefore` overrides the default ` · ` separator rendered before this
|
|
44
|
+
* segment (the first segment renders no separator).
|
|
45
|
+
*/
|
|
46
|
+
type Segment = string | { prefix: string; tail: string; color: ThemeColor; sepBefore?: string };
|
|
47
|
+
type ElasticSegment = { prefix: string; tail: string; color: ThemeColor; sepBefore?: string };
|
|
48
|
+
|
|
49
|
+
function renderSegment(seg: Segment, theme: Theme): string {
|
|
50
|
+
return typeof seg === "string" ? seg : theme.fg(seg.color, `${seg.prefix}${seg.tail}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assemble(segments: Segment[], theme: Theme): string {
|
|
54
|
+
return segments
|
|
55
|
+
.map((s, i) => {
|
|
56
|
+
const body = renderSegment(s, theme);
|
|
57
|
+
if (i === 0) return body;
|
|
58
|
+
const sep = typeof s === "string" ? SEP : (s.sepBefore ?? SEP);
|
|
59
|
+
return `${sep}${body}`;
|
|
60
|
+
})
|
|
61
|
+
.join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Assemble segments with ` · ` separators; if the line overflows the terminal,
|
|
66
|
+
* middle-elide the elastic-segment tails, sharing the available width
|
|
67
|
+
* proportionally across whatever elastic segments are present. Fixed segments
|
|
68
|
+
* are never shortened.
|
|
69
|
+
*
|
|
70
|
+
* Which segments are elastic varies by phase: the feature slug appears only
|
|
71
|
+
* when a slug exists, the coarse plan-task only in implement when one is set.
|
|
72
|
+
* The proportional allocation therefore runs over the actual set (0, 1, or 2
|
|
73
|
+
* tails), so a lone slug gets the whole budget and a slug + task pair split it.
|
|
74
|
+
*/
|
|
75
|
+
function fitLine(segments: Segment[], theme: Theme, termWidth: number): string {
|
|
76
|
+
// Indices of elastic segments and their full tail widths.
|
|
77
|
+
const elasticIdx = segments.map((s, i) => (typeof s === "string" ? -1 : i)).filter((i) => i >= 0);
|
|
78
|
+
if (elasticIdx.length === 0 || visibleWidth(assemble(segments, theme)) <= termWidth) {
|
|
79
|
+
return assemble(segments, theme);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const fullWidths = elasticIdx.map((i) => visibleWidth((segments[i] as ElasticSegment).tail));
|
|
83
|
+
// Width of everything except the elastic tail content (fixed segments +
|
|
84
|
+
// separators + the elastic segments' fixed prefixes).
|
|
85
|
+
const emptied = segments.map((s) => (typeof s === "string" ? s : { ...s, tail: "" }));
|
|
86
|
+
const availForTails = termWidth - visibleWidth(assemble(emptied, theme));
|
|
87
|
+
|
|
88
|
+
const budgets = proportionalBudgets(fullWidths, Math.max(availForTails, 0));
|
|
89
|
+
for (let k = 0; k < elasticIdx.length; k++) {
|
|
90
|
+
const i = elasticIdx[k] as number;
|
|
91
|
+
const seg = segments[i] as ElasticSegment;
|
|
92
|
+
segments[i] = { ...seg, tail: elideMiddle(seg.tail, budgets[k] as number) };
|
|
93
|
+
}
|
|
94
|
+
return assemble(segments, theme);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Allocate `total` visible cells across segments proportionally to their full
|
|
99
|
+
* widths, each clamped to [1, fullWidth] (so every name stays visible), with
|
|
100
|
+
* the remainder distributed to the largest fractional shares. If `total` is
|
|
101
|
+
* already enough, returns the full widths unchanged (no trimming).
|
|
102
|
+
*/
|
|
103
|
+
function proportionalBudgets(fullWidths: number[], total: number): number[] {
|
|
104
|
+
const n = fullWidths.length;
|
|
105
|
+
const sum = fullWidths.reduce((a, b) => a + b, 0);
|
|
106
|
+
if (total >= sum) return fullWidths.slice(); // nothing to trim
|
|
107
|
+
if (total <= 0) return fullWidths.map(() => 0);
|
|
108
|
+
|
|
109
|
+
// Guarantee each tail at least 1 cell when the budget allows.
|
|
110
|
+
let budget = total;
|
|
111
|
+
const minPer = total >= n ? 1 : 0;
|
|
112
|
+
budget -= minPer * n;
|
|
113
|
+
|
|
114
|
+
// Proportional floor of the variable share.
|
|
115
|
+
const raw = fullWidths.map((w) => (sum > 0 ? (w * budget) / sum : 0));
|
|
116
|
+
const floors = raw.map((r) => Math.max(0, Math.floor(r)));
|
|
117
|
+
let leftover = budget - floors.reduce((a, b) => a + b, 0);
|
|
118
|
+
|
|
119
|
+
// Hand leftover cells to the largest fractional remainders, capped at fullWidth-1.
|
|
120
|
+
const cap = fullWidths.map((w) => w - minPer);
|
|
121
|
+
const order = fullWidths.map((_, i) => i).sort((a, b) => raw[b] - Math.floor(raw[b]) - (raw[a] - Math.floor(raw[a])));
|
|
122
|
+
let k = 0;
|
|
123
|
+
while (leftover > 0 && k < n * 4) {
|
|
124
|
+
const i = order[k % n] as number;
|
|
125
|
+
if (floors[i] < (cap[i] ?? 0)) {
|
|
126
|
+
floors[i] += 1;
|
|
127
|
+
leftover--;
|
|
128
|
+
}
|
|
129
|
+
k++;
|
|
130
|
+
}
|
|
131
|
+
return floors.map((f) => f + minPer);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Middle-elide `text` to at most `maxLen` visible chars: keep head + tail, `…` between. */
|
|
135
|
+
function elideMiddle(text: string, maxLen: number): string {
|
|
136
|
+
if (maxLen <= 1) return "\u2026";
|
|
137
|
+
if (text.length <= maxLen) return text;
|
|
138
|
+
const keep = maxLen - 1; // reserve 1 cell for the ellipsis
|
|
139
|
+
const headLen = Math.ceil(keep / 2);
|
|
140
|
+
const tailLen = keep - headLen;
|
|
141
|
+
return `${text.slice(0, headLen)}\u2026${text.slice(text.length - tailLen)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Phase bar ---
|
|
145
|
+
|
|
146
|
+
/** One square cell per phase: ■ done, ◧ active, □ pending. When the feature is complete, all cells are ■. */
|
|
147
|
+
function formatPhaseBar(currentPhase: Phase, theme: Theme, isDone: boolean): string {
|
|
148
|
+
const view = { currentPhase, completedAt: null };
|
|
149
|
+
return WORKFLOW_PHASES.map((phase) => {
|
|
150
|
+
if (isDone) return theme.fg("success", "■");
|
|
151
|
+
if (phase === currentPhase) return theme.fg("accent", "◧");
|
|
152
|
+
if (isPhaseDone(view, phase)) return theme.fg("success", "■");
|
|
153
|
+
return theme.fg("dim", "□");
|
|
154
|
+
}).join("");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Active phase label with an inline review-loop count where applicable. */
|
|
158
|
+
function formatPhaseLabel(
|
|
159
|
+
phase: Phase,
|
|
160
|
+
reviewLoopCount: number,
|
|
161
|
+
designReviewLoopCount: number,
|
|
162
|
+
planReviewLoopCount: number,
|
|
163
|
+
): string {
|
|
164
|
+
let label: string = phase;
|
|
165
|
+
if (phase === "review" && reviewLoopCount > 0) label = `${phase} #${reviewLoopCount}`;
|
|
166
|
+
else if (phase === "design" && designReviewLoopCount > 0) label = `${phase} #${designReviewLoopCount}`;
|
|
167
|
+
else if (phase === "plan" && planReviewLoopCount > 0) label = `${phase} #${planReviewLoopCount}`;
|
|
168
|
+
return label;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Main render ---
|
|
172
|
+
|
|
173
|
+
export function updateWidget(handler: FeatureSession, featureState: FeatureState | null): void {
|
|
174
|
+
const guard = globalThis.__piCtx;
|
|
175
|
+
if (!guard?.hasUI || !guard?.ui) return;
|
|
176
|
+
const ui = guard.ui;
|
|
177
|
+
|
|
178
|
+
const workflow = handler.getWorkflowState();
|
|
179
|
+
const currentPhase = workflow?.currentPhase ?? null;
|
|
180
|
+
const hasWorkflow = !!currentPhase;
|
|
181
|
+
|
|
182
|
+
// Check auto-agent presence BEFORE early-return
|
|
183
|
+
const autoAgent = globalThis.__piKanban?.autoAgent;
|
|
184
|
+
const autoAgentState = autoAgent ? autoAgent.getState() : null;
|
|
185
|
+
const hasAutoAgent = autoAgentState ? !["idle", "stopped"].includes(autoAgentState) : false;
|
|
186
|
+
|
|
187
|
+
if (!hasWorkflow && !hasAutoAgent) {
|
|
188
|
+
ui.setWidget("workflow_monitor", NO_WIDGET_CONTENT);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
ui.setWidget("workflow_monitor", (_tui, theme) => {
|
|
193
|
+
const segments: Segment[] = [];
|
|
194
|
+
|
|
195
|
+
// Phase prefix block: 🅵 + bar + active-phase label (only when a workflow is active).
|
|
196
|
+
if (currentPhase) {
|
|
197
|
+
const slug = handler.getActiveFeatureSlug();
|
|
198
|
+
const state = featureState ?? handler.getActiveFeatureState();
|
|
199
|
+
const isDone = state?.completedAt != null;
|
|
200
|
+
const reviewLoopCount = state?.review.reviewLoopCount ?? 0;
|
|
201
|
+
const designReviewLoopCount = state?.design.reviewLoopCount ?? 0;
|
|
202
|
+
const planReviewLoopCount = state?.plan.reviewLoopCount ?? 0;
|
|
203
|
+
const featureId = state?.featureId ?? null;
|
|
204
|
+
|
|
205
|
+
const bar = formatPhaseBar(currentPhase, theme, isDone);
|
|
206
|
+
const phaseLabel = isDone
|
|
207
|
+
? "done"
|
|
208
|
+
: formatPhaseLabel(currentPhase, reviewLoopCount, designReviewLoopCount, planReviewLoopCount);
|
|
209
|
+
segments.push(`${theme.fg("accent", "\u{1F175}")} ${bar} ${theme.fg("accent", phaseLabel)}`);
|
|
210
|
+
|
|
211
|
+
// Feature identity (id + slug). Slug is elastic.
|
|
212
|
+
if (slug) {
|
|
213
|
+
const prefix = featureId != null ? `${featureId}. ` : "";
|
|
214
|
+
segments.push({ prefix, tail: slug, color: "muted" });
|
|
215
|
+
|
|
216
|
+
// Coarse plan-task (set by task_ready_advance, durable in feature-state).
|
|
217
|
+
// Elastic, shown only in implement.
|
|
218
|
+
const currentTask = state?.implement.currentTask ?? null;
|
|
219
|
+
if (currentPhase === "implement" && currentTask) {
|
|
220
|
+
// The ▶ marker doubles as the separator before the task (replaces ` · `),
|
|
221
|
+
// so prefix is empty and sepBefore carries ` ▶ `.
|
|
222
|
+
segments.push({ prefix: "", tail: currentTask, color: "accent", sepBefore: " ▶ " });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Auto-agent token, rendered after feature identity.
|
|
228
|
+
if (autoAgent && autoAgentState) {
|
|
229
|
+
const role = autoAgent.getRole();
|
|
230
|
+
const gracePeriodManager = globalThis.__piKanban?.gracePeriod;
|
|
231
|
+
if (autoAgentState === "grace-period") {
|
|
232
|
+
const remaining = gracePeriodManager?.getRemainingSeconds() ?? 0;
|
|
233
|
+
segments.push(theme.fg("warning", `\u{1F916} auto-${role} \u23F3 ${remaining}s`));
|
|
234
|
+
} else if (autoAgentState === "working") {
|
|
235
|
+
segments.push(theme.fg("success", `\u{1F916} auto-${role}`));
|
|
236
|
+
} else if (autoAgentState === "polling") {
|
|
237
|
+
segments.push(theme.fg("muted", `\u{1F4A4} auto-${role}`));
|
|
238
|
+
} else if (autoAgentState === "waiting") {
|
|
239
|
+
segments.push(theme.fg("warning", `\u{1F916} auto-${role}`));
|
|
240
|
+
} else if (autoAgentState === "paused") {
|
|
241
|
+
segments.push(theme.fg("warning", `\u23F8\uFE0F auto-${role}`));
|
|
242
|
+
} else if (autoAgentState === "error") {
|
|
243
|
+
segments.push(theme.fg("error", `\u274C auto-${role}`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (segments.length === 0) return new Text("", 0, 0);
|
|
248
|
+
return new Text(fitLine(segments, theme, getTermWidth()), 0, 0);
|
|
249
|
+
});
|
|
250
|
+
}
|