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,629 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
import { log, NO_ERROR } from "../../log.js";
|
|
5
|
+
import type { Feature, Lane } from "../data/kanban-types.js";
|
|
6
|
+
import type { KanbanTools } from "../kanban-operations.js";
|
|
7
|
+
|
|
8
|
+
/** Compute target lane for feature completion. Shared between handleFeatureCompletion and onFeatureComplete callback. */
|
|
9
|
+
export function computeTargetLane(currentFeatureLane: Lane, designApprovalEnabled: boolean): Lane {
|
|
10
|
+
if (currentFeatureLane === "design") {
|
|
11
|
+
return designApprovalEnabled ? "design-approval" : "ready";
|
|
12
|
+
}
|
|
13
|
+
return "done";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AutoAgentState =
|
|
17
|
+
| "idle"
|
|
18
|
+
| "working"
|
|
19
|
+
| "waiting"
|
|
20
|
+
| "polling"
|
|
21
|
+
| "grace-period"
|
|
22
|
+
| "paused"
|
|
23
|
+
| "stopped"
|
|
24
|
+
| "error";
|
|
25
|
+
export type AutoAgentRole = "worker" | "designer" | "agent";
|
|
26
|
+
|
|
27
|
+
/** Lanes each role picks from (ordered by priority) */
|
|
28
|
+
const ROLE_LANES: Record<AutoAgentRole, Lane[]> = {
|
|
29
|
+
worker: ["in-progress", "ready"],
|
|
30
|
+
designer: ["design"],
|
|
31
|
+
agent: ["in-progress", "design", "ready"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Skill to inject based on the lane the feature was picked from */
|
|
35
|
+
const LANE_TO_SKILL: Record<string, string> = {
|
|
36
|
+
design: "fy-design",
|
|
37
|
+
ready: "fy-plan",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Cross-extension bridge: set by kanban extension, called by workflow-monitor
|
|
41
|
+
// Stored on globalThis.__piKanban.autoAgentCallback
|
|
42
|
+
|
|
43
|
+
export interface AutoAgentCallback {
|
|
44
|
+
onFeatureComplete: (slug: string) => void;
|
|
45
|
+
onFeatureError: (slug: string, error: string) => void;
|
|
46
|
+
onBlock?: (slug: string) => void;
|
|
47
|
+
onUnblock?: (slug: string) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Called when a feature reaches UAT (after-review mode) and the agent should
|
|
50
|
+
* release the lock and move to the next feature. The card stays in the UAT lane
|
|
51
|
+
* for the user to accept/reject.
|
|
52
|
+
*/
|
|
53
|
+
onFeatureUatHandoff?: (slug: string) => void;
|
|
54
|
+
/** Returns true if any auto-agent is currently active (working/polling/waiting). */
|
|
55
|
+
isActive?: () => boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function setAutoAgentCallback(cb: AutoAgentCallback | null): void {
|
|
59
|
+
if (globalThis.__piKanban) {
|
|
60
|
+
globalThis.__piKanban.autoAgentCallback = cb ?? undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Sentinel for setAutoAgentCallback() — clear the registered auto-agent callback. */
|
|
65
|
+
export const NO_AUTO_AGENT_CALLBACK: AutoAgentCallback | null = null;
|
|
66
|
+
|
|
67
|
+
export function getAutoAgentCallback(): AutoAgentCallback | null {
|
|
68
|
+
return globalThis.__piKanban?.autoAgentCallback ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pass as `status` to clear the overlay. */
|
|
72
|
+
const NO_OVERLAY_STATUS: string | null = null;
|
|
73
|
+
|
|
74
|
+
/** Start the agent heartbeat (refresh kanban heartbeat via getTools every interval) + the wait-timeout watchdog for the given feature. Shared by auto-agent-lifecycle (onActivate) and register-auto-agent (tryMatchSessionSlug). */
|
|
75
|
+
export function startAgentHeartbeat(
|
|
76
|
+
sm: AutoAgentStateMachine,
|
|
77
|
+
featureId: number,
|
|
78
|
+
getTools: () => Promise<KanbanTools>,
|
|
79
|
+
): void {
|
|
80
|
+
sm.startHeartbeat(featureId, (fid) => {
|
|
81
|
+
getTools()
|
|
82
|
+
.then((t) => t.kanbanHeartbeat(fid, sm.sessionId))
|
|
83
|
+
.catch((e) => log.warn(`[kanban] heartbeat refresh failed: ${e}`));
|
|
84
|
+
});
|
|
85
|
+
sm.startWaitTimeoutForFeature(featureId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class AutoAgentStateMachine {
|
|
89
|
+
private state: AutoAgentState = "idle";
|
|
90
|
+
private pauseRequested = false;
|
|
91
|
+
private currentFeatureId: number | null = null;
|
|
92
|
+
private currentFeatureLane: Lane | null = null;
|
|
93
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
94
|
+
private overlayCallback: ((featureId: number, status: string | null) => void) | null = null;
|
|
95
|
+
private waitTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
96
|
+
private pollingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
97
|
+
private startPollingFn: (() => void) | null = null;
|
|
98
|
+
private waitTimeoutMs: number | null = null;
|
|
99
|
+
private onWaitTimeout: ((featureId: number, reason: "timeout") => void) | null = null;
|
|
100
|
+
|
|
101
|
+
// The role is the one mutable field that changes at runtime (role switch).
|
|
102
|
+
// It is exposed read-only via the getRole() method so external callers cannot
|
|
103
|
+
// bypass setRole() (which logs + persists). Mutation must go through setRole().
|
|
104
|
+
private _role: AutoAgentRole;
|
|
105
|
+
getRole(): AutoAgentRole {
|
|
106
|
+
return this._role;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
constructor(
|
|
110
|
+
role: AutoAgentRole,
|
|
111
|
+
public readonly projectId: number | undefined,
|
|
112
|
+
public readonly sessionId: string,
|
|
113
|
+
) {
|
|
114
|
+
this._role = role;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Switch the agent's role in place (e.g. worker → designer) without tearing
|
|
119
|
+
* down and recreating the state machine. Preserves the current feature, its
|
|
120
|
+
* lock (same sessionId), all timers, and callback wiring. The next feature
|
|
121
|
+
* pick will use the new role's lanes.
|
|
122
|
+
*/
|
|
123
|
+
setRole(role: AutoAgentRole): void {
|
|
124
|
+
if (this._role === role) return;
|
|
125
|
+
log.info(`[auto-agent] role changed ${this._role} → ${role}`);
|
|
126
|
+
this._role = role;
|
|
127
|
+
this.persist();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Configure wait timeout behavior for this agent. */
|
|
131
|
+
setWaitTimeoutConfig(timeoutMs: number | null, onTimeout: (featureId: number, reason: "timeout") => void): void {
|
|
132
|
+
this.waitTimeoutMs = timeoutMs;
|
|
133
|
+
this.onWaitTimeout = onTimeout;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Update only the wait-timeout duration, keeping the existing onTimeout callback.
|
|
137
|
+
* Used on role switch (e.g. worker → designer) so the next waiting state uses
|
|
138
|
+
* the new role's configured timeout instead of the stale prior value. */
|
|
139
|
+
setWaitTimeoutMs(timeoutMs: number | null): void {
|
|
140
|
+
this.waitTimeoutMs = timeoutMs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The currently configured wait-timeout duration (or null if disabled).
|
|
144
|
+
* Exposed so callers/tests can verify the value (e.g. after a role switch). */
|
|
145
|
+
getWaitTimeoutMs(): number | null {
|
|
146
|
+
return this.waitTimeoutMs;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Adopt an existing feature (set private fields) for session-slug matching.
|
|
150
|
+
* Used when auto-agent starts in a session already working on a feature. */
|
|
151
|
+
adoptFeature(featureId: number, lane: Lane): void {
|
|
152
|
+
this.currentFeatureId = featureId;
|
|
153
|
+
this.currentFeatureLane = lane;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Start wait timeout for a specific feature using stored config. */
|
|
157
|
+
startWaitTimeoutForFeature(featureId: number): void {
|
|
158
|
+
if (this.waitTimeoutMs !== null && this.onWaitTimeout !== null) {
|
|
159
|
+
this.startWaitTimeout(featureId, this.waitTimeoutMs, this.onWaitTimeout);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private persist(): void {
|
|
164
|
+
// No-op: state lives in globalThis, no persistence needed.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getState(): AutoAgentState {
|
|
168
|
+
return this.state;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
start(): boolean {
|
|
172
|
+
if (this.state !== "idle") return false;
|
|
173
|
+
this.state = "working";
|
|
174
|
+
this.pauseRequested = false;
|
|
175
|
+
log.info(`[auto-agent] started (${this.getRole()})`);
|
|
176
|
+
this.persist();
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
complete(): void {
|
|
181
|
+
this.stopHeartbeat();
|
|
182
|
+
this.stopWaitTimeout();
|
|
183
|
+
if (this.state !== "working" && this.state !== "waiting" && this.state !== "paused") return;
|
|
184
|
+
// Clear overlay status if in waiting state
|
|
185
|
+
if (this.state === "waiting") this.clearOverlayIfBlocked();
|
|
186
|
+
this.currentFeatureId = null;
|
|
187
|
+
if (this.pauseRequested) {
|
|
188
|
+
this.state = "stopped";
|
|
189
|
+
log.info("[auto-agent] stopped after feature completion");
|
|
190
|
+
} else {
|
|
191
|
+
this.state = "idle";
|
|
192
|
+
log.info("[auto-agent] feature complete, returning to idle");
|
|
193
|
+
}
|
|
194
|
+
this.persist();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
noFeatureAvailable(): void {
|
|
198
|
+
if (this.state !== "working") return;
|
|
199
|
+
if (this.pauseRequested) {
|
|
200
|
+
this.state = "stopped";
|
|
201
|
+
} else {
|
|
202
|
+
this.state = "polling";
|
|
203
|
+
}
|
|
204
|
+
this.persist();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
featureFound(): void {
|
|
208
|
+
if (this.state !== "polling") return;
|
|
209
|
+
this.state = "working";
|
|
210
|
+
this.persist();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Immediately and unconditionally stop the agent from any state.
|
|
215
|
+
*
|
|
216
|
+
* Clears all timers (heartbeat, polling, wait-timeout) and clears any
|
|
217
|
+
* "waiting-for-response" overlay, then transitions to "stopped". This is an
|
|
218
|
+
* immediate stop — there is no deferred "finish current feature first"
|
|
219
|
+
* behavior. Callers that need the current feature's lock preserved must
|
|
220
|
+
* reassign it themselves (e.g. /fy:auto-stop reassigns the UUID lock to
|
|
221
|
+
* session:<slug> so it survives without a heartbeat).
|
|
222
|
+
*/
|
|
223
|
+
requestStop(): void {
|
|
224
|
+
this.stopHeartbeat();
|
|
225
|
+
this.stopPollingTimer();
|
|
226
|
+
this.stopWaitTimeout();
|
|
227
|
+
// Clear waiting-for-response overlay if the agent was blocked
|
|
228
|
+
if (this.state === "waiting") this.clearOverlayIfBlocked();
|
|
229
|
+
this.state = "stopped";
|
|
230
|
+
log.info(`[auto-agent] stopped (${this.getRole()})`);
|
|
231
|
+
this.persist();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Pause the auto-loop: stops polling, wait timeout, and auto-pick of next feature.
|
|
236
|
+
* Keeps heartbeat running and preserves current feature assignment.
|
|
237
|
+
* The user can continue working on the current feature manually.
|
|
238
|
+
* To resume auto-loop, call unpause().
|
|
239
|
+
*/
|
|
240
|
+
pause(): void {
|
|
241
|
+
this.stopPollingTimer();
|
|
242
|
+
this.stopWaitTimeout();
|
|
243
|
+
this.pauseRequested = true;
|
|
244
|
+
if (this.state === "grace-period") {
|
|
245
|
+
// Grace period manager is stopped externally by the caller
|
|
246
|
+
}
|
|
247
|
+
this.state = "paused";
|
|
248
|
+
log.info(`[auto-agent] paused (keeping heartbeat for feature ${this.currentFeatureId})`);
|
|
249
|
+
this.persist();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Unpause the auto-loop: resumes from paused state.
|
|
254
|
+
* Only works if in "paused" state.
|
|
255
|
+
*/
|
|
256
|
+
unpause(): boolean {
|
|
257
|
+
if (this.state !== "paused") return false;
|
|
258
|
+
this.pauseRequested = false;
|
|
259
|
+
this.state = "working";
|
|
260
|
+
log.info("[auto-agent] unpaused (resuming auto-loop)");
|
|
261
|
+
this.persist();
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
error(message: string): void {
|
|
266
|
+
this.stopHeartbeat();
|
|
267
|
+
this.stopWaitTimeout();
|
|
268
|
+
this.stopPollingTimer();
|
|
269
|
+
// Clear overlay status on error
|
|
270
|
+
this.clearOverlayIfBlocked();
|
|
271
|
+
log.error(`[auto-agent] error: ${message}`, NO_ERROR);
|
|
272
|
+
this.state = "error";
|
|
273
|
+
this.persist();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
reset(): void {
|
|
277
|
+
this.stopHeartbeat();
|
|
278
|
+
this.stopWaitTimeout();
|
|
279
|
+
this.stopPollingTimer();
|
|
280
|
+
this.state = "idle";
|
|
281
|
+
this.pauseRequested = false;
|
|
282
|
+
this.persist();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Start a periodic heartbeat timer to keep the feature lock alive.
|
|
287
|
+
* @param featureId The feature to refresh the lock for
|
|
288
|
+
* @param heartbeatFn Called with featureId on each interval tick
|
|
289
|
+
* @param intervalMs Interval in milliseconds (default 60000 = 60s)
|
|
290
|
+
*/
|
|
291
|
+
startHeartbeat(featureId: number, heartbeatFn: (featureId: number) => void, intervalMs = 60_000): void {
|
|
292
|
+
this.stopHeartbeat();
|
|
293
|
+
this.heartbeatTimer = setInterval(() => {
|
|
294
|
+
heartbeatFn(featureId);
|
|
295
|
+
}, intervalMs);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Stop the heartbeat timer if running. */
|
|
299
|
+
stopHeartbeat(): void {
|
|
300
|
+
if (this.heartbeatTimer !== null) {
|
|
301
|
+
clearInterval(this.heartbeatTimer);
|
|
302
|
+
this.heartbeatTimer = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Start a wait timeout timer. If the agent is blocked for longer than
|
|
308
|
+
* timeoutMs, the onTimeout callback fires with the feature ID.
|
|
309
|
+
* Pass null for timeoutMs to wait indefinitely (no timer set).
|
|
310
|
+
*/
|
|
311
|
+
startWaitTimeout(
|
|
312
|
+
featureId: number,
|
|
313
|
+
timeoutMs: number | null,
|
|
314
|
+
onTimeout: (featureId: number, reason: "timeout") => void,
|
|
315
|
+
): void {
|
|
316
|
+
this.stopWaitTimeout();
|
|
317
|
+
if (timeoutMs === null) return; // Infinite wait — no timer
|
|
318
|
+
this.waitTimeoutTimer = setTimeout(() => {
|
|
319
|
+
if (this.state === "waiting") {
|
|
320
|
+
onTimeout(featureId, "timeout");
|
|
321
|
+
}
|
|
322
|
+
}, timeoutMs);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Stop the wait timeout timer if running. */
|
|
326
|
+
stopWaitTimeout(): void {
|
|
327
|
+
if (this.waitTimeoutTimer !== null) {
|
|
328
|
+
clearTimeout(this.waitTimeoutTimer);
|
|
329
|
+
this.waitTimeoutTimer = null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Set the polling timer from external code (index.ts). Timer is cleared on stop/reset/error. */
|
|
334
|
+
setPollingTimer(timer: ReturnType<typeof setTimeout>): void {
|
|
335
|
+
if (this.pollingTimer !== null) {
|
|
336
|
+
clearTimeout(this.pollingTimer);
|
|
337
|
+
}
|
|
338
|
+
this.pollingTimer = timer;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Set the function to start a new polling cycle (used by onFeatureComplete to enter polling). */
|
|
342
|
+
setStartPollingFn(fn: (() => void) | null): void {
|
|
343
|
+
this.startPollingFn = fn;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Get the start polling function (for calling from onFeatureComplete). */
|
|
347
|
+
getStartPollingFn(): (() => void) | null {
|
|
348
|
+
return this.startPollingFn;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Stop the polling timer if running. */
|
|
352
|
+
stopPollingTimer(): void {
|
|
353
|
+
if (this.pollingTimer !== null) {
|
|
354
|
+
clearTimeout(this.pollingTimer);
|
|
355
|
+
this.pollingTimer = null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Set the callback for managing overlay status on the current feature.
|
|
361
|
+
* Called with (featureId, status) where status is 'waiting-for-response' or null.
|
|
362
|
+
*/
|
|
363
|
+
setOverlayCallback(cb: (featureId: number, status: string | null) => void): void {
|
|
364
|
+
this.overlayCallback = cb;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Clear the "waiting-for-response" overlay for the current feature, if any is set.
|
|
368
|
+
* Shared by complete()/requestStop()/error()/unblock() so the clear logic
|
|
369
|
+
* (and its null-guards) lives in one place. Callers that should only clear
|
|
370
|
+
* when actually blocked guard on state themselves. */
|
|
371
|
+
private clearOverlayIfBlocked(): void {
|
|
372
|
+
if (this.currentFeatureId !== null && this.overlayCallback) {
|
|
373
|
+
this.overlayCallback(this.currentFeatureId, NO_OVERLAY_STATUS);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Set the "waiting-for-response" overlay on the current feature. */
|
|
378
|
+
private setOverlayWaitingForResponse(): void {
|
|
379
|
+
if (this.currentFeatureId !== null && this.overlayCallback) {
|
|
380
|
+
this.overlayCallback(this.currentFeatureId, "waiting-for-response");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Enter waiting state and set overlay status on the current feature.
|
|
386
|
+
* Called when the agent needs user input (e.g., ask_user_question).
|
|
387
|
+
*/
|
|
388
|
+
block(): void {
|
|
389
|
+
if (this.state !== "working") return;
|
|
390
|
+
this.state = "waiting";
|
|
391
|
+
this.setOverlayWaitingForResponse();
|
|
392
|
+
log.info(`[auto-agent] blocked, waiting for response (feature ${this.currentFeatureId})`);
|
|
393
|
+
this.persist();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Resume from waiting state and clear overlay status on the current feature.
|
|
398
|
+
* Called when user provides input and agent can continue.
|
|
399
|
+
*/
|
|
400
|
+
unblock(): void {
|
|
401
|
+
if (this.state !== "waiting") return;
|
|
402
|
+
this.stopWaitTimeout();
|
|
403
|
+
this.state = "working";
|
|
404
|
+
this.clearOverlayIfBlocked();
|
|
405
|
+
log.info(`[auto-agent] unblocked, resuming work (feature ${this.currentFeatureId})`);
|
|
406
|
+
this.persist();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Enter grace period state (from polling or working). Feature found but waiting before activating. */
|
|
410
|
+
enterGracePeriod(): void {
|
|
411
|
+
if (this.state !== "polling" && this.state !== "working") return;
|
|
412
|
+
this.state = "grace-period";
|
|
413
|
+
this.persist();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Exit grace period (feature activated). */
|
|
417
|
+
exitGracePeriod(): void {
|
|
418
|
+
if (this.state !== "grace-period") return;
|
|
419
|
+
this.state = "working";
|
|
420
|
+
this.persist();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get the lanes this role picks from.
|
|
425
|
+
*/
|
|
426
|
+
getLanes(): Lane[] {
|
|
427
|
+
return ROLE_LANES[this.getRole()];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Get the current feature ID being worked on */
|
|
431
|
+
getCurrentFeatureId(): number | null {
|
|
432
|
+
return this.currentFeatureId;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Get the original lane of the current feature (before kanbanTake moved it) */
|
|
436
|
+
getCurrentFeatureLane(): Lane | null {
|
|
437
|
+
return this.currentFeatureLane;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Pick the next feature from kanban using the appropriate lanes for this role.
|
|
442
|
+
* Returns the feature + skill to inject, or null if no feature available.
|
|
443
|
+
*/
|
|
444
|
+
pickNextFeature(
|
|
445
|
+
tools: KanbanTools,
|
|
446
|
+
projectId: number,
|
|
447
|
+
sessionId: string,
|
|
448
|
+
): { feature: Feature; skill: string; kanbanFeatureId: number } | null {
|
|
449
|
+
if (this.state !== "working" && this.state !== "idle") return null;
|
|
450
|
+
|
|
451
|
+
// Try lanes in order (design-first for agent role) to respect lane priority
|
|
452
|
+
let feature: Feature | null = null;
|
|
453
|
+
let foundInLane: Lane | null = null;
|
|
454
|
+
const lanes = this.getLanes();
|
|
455
|
+
for (const lane of lanes) {
|
|
456
|
+
feature = tools.kanbanTake({
|
|
457
|
+
projectId,
|
|
458
|
+
lanes: [lane],
|
|
459
|
+
sessionId,
|
|
460
|
+
});
|
|
461
|
+
if (feature) {
|
|
462
|
+
foundInLane = lane;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!feature) {
|
|
468
|
+
this.noFeatureAvailable();
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Track current feature for completion/error handling
|
|
473
|
+
this.currentFeatureId = feature.id;
|
|
474
|
+
// Use the lane we searched when the feature was found, not the (possibly moved) feature.lane.
|
|
475
|
+
// kanbanTake moves ready→in-progress, so feature.lane may differ from the search lane.
|
|
476
|
+
this.currentFeatureLane = foundInLane;
|
|
477
|
+
const skill = foundInLane ? LANE_TO_SKILL[foundInLane] : "fy-design";
|
|
478
|
+
|
|
479
|
+
// Handle null slug: assign temp slug kanban-{id}
|
|
480
|
+
const resolvedFeature = feature.slug ? feature : { ...feature, slug: `kanban-${feature.id}` as string };
|
|
481
|
+
if (!feature.slug) {
|
|
482
|
+
log.info(`[auto-agent] assigned temp slug ${resolvedFeature.slug} to feature ${feature.id}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
log.info(
|
|
486
|
+
`[auto-agent] picked feature ${resolvedFeature.slug} from ${this.getLanes().join(",")} lane(s), injecting skill: ${skill}`,
|
|
487
|
+
);
|
|
488
|
+
return { feature: resolvedFeature, skill, kanbanFeatureId: feature.id };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Handle completion of a feature: move to appropriate lane, release lock, check for next.
|
|
493
|
+
* Design features move to ready (or design-approval if enabled).
|
|
494
|
+
* Implementation features move to done.
|
|
495
|
+
* Returns the next feature+skill pair if the loop should continue, or null.
|
|
496
|
+
*/
|
|
497
|
+
handleFeatureCompletion(
|
|
498
|
+
tools: KanbanTools,
|
|
499
|
+
featureId: number,
|
|
500
|
+
sessionId: string,
|
|
501
|
+
projectId: number,
|
|
502
|
+
opts?: { designApprovalEnabled?: boolean; completeOnly?: boolean },
|
|
503
|
+
): { feature: Feature; skill: string; kanbanFeatureId: number } | null {
|
|
504
|
+
// Determine target lane based on original feature lane
|
|
505
|
+
const designApprovalEnabled = opts?.designApprovalEnabled ?? true;
|
|
506
|
+
if (!this.currentFeatureLane) {
|
|
507
|
+
log.warn(`[auto-agent] handleFeatureCompletion: no currentFeatureLane for featureId=${featureId}`);
|
|
508
|
+
this.complete();
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const targetLane = computeTargetLane(this.currentFeatureLane, designApprovalEnabled);
|
|
512
|
+
|
|
513
|
+
log.info(
|
|
514
|
+
`[auto-agent] handleFeatureCompletion: featureId=${featureId}, currentFeatureLane=${this.currentFeatureLane}, targetLane=${targetLane}, designApprovalEnabled=${designApprovalEnabled}`,
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
// Move feature to target lane and release lock
|
|
518
|
+
tools.kanbanMove({
|
|
519
|
+
featureId,
|
|
520
|
+
toLane: targetLane,
|
|
521
|
+
changedBy: `agent:${sessionId}`,
|
|
522
|
+
});
|
|
523
|
+
log.info(`[auto-agent] handleFeatureCompletion: moved feature ${featureId} to ${targetLane}`);
|
|
524
|
+
tools.kanbanRelease({ featureId });
|
|
525
|
+
log.info(`[auto-agent] handleFeatureCompletion: released lock on feature ${featureId}`);
|
|
526
|
+
|
|
527
|
+
// Complete current feature in state machine
|
|
528
|
+
this.complete();
|
|
529
|
+
log.info(`[auto-agent] handleFeatureCompletion: state after complete()=${this.getState()}`);
|
|
530
|
+
|
|
531
|
+
// If stopped (user requested stop), don't pick next
|
|
532
|
+
if (this.getState() === "stopped") {
|
|
533
|
+
log.info("[auto-agent] stop was requested, not picking next feature");
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// completeOnly: stop here — caller handles start() + pick/enterGracePeriod
|
|
538
|
+
if (opts?.completeOnly) return null;
|
|
539
|
+
|
|
540
|
+
// Start next cycle
|
|
541
|
+
if (!this.start()) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return this.pickNextFeature(tools, projectId, sessionId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Handle UAT handoff: release lock (card stays in UAT lane), complete current feature,
|
|
550
|
+
* and pick the next feature.
|
|
551
|
+
* Used in after-review UAT mode where the user accepts/rejects in the UAT lane.
|
|
552
|
+
* Returns the next feature+skill pair if the loop should continue, or null.
|
|
553
|
+
*/
|
|
554
|
+
handleFeatureUatHandoff(
|
|
555
|
+
tools: KanbanTools,
|
|
556
|
+
featureId: number,
|
|
557
|
+
sessionId: string,
|
|
558
|
+
projectId: number,
|
|
559
|
+
): { feature: Feature; skill: string; kanbanFeatureId: number } | null {
|
|
560
|
+
log.info(
|
|
561
|
+
`[auto-agent] handleFeatureUatHandoff: featureId=${featureId}, currentFeatureLane=${this.currentFeatureLane}`,
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// Release lock — card stays in UAT lane (already moved by sync callback)
|
|
565
|
+
tools.kanbanRelease({ featureId });
|
|
566
|
+
log.info(`[auto-agent] handleFeatureUatHandoff: released lock on feature ${featureId}`);
|
|
567
|
+
|
|
568
|
+
// Complete current feature in state machine
|
|
569
|
+
this.complete();
|
|
570
|
+
log.info(`[auto-agent] handleFeatureUatHandoff: state after complete()=${this.getState()}`);
|
|
571
|
+
|
|
572
|
+
// If stopped (user requested stop), don't pick next
|
|
573
|
+
if (this.getState() === "stopped") {
|
|
574
|
+
log.info("[auto-agent] stop was requested, not picking next feature");
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Start next cycle
|
|
579
|
+
if (!this.start()) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return this.pickNextFeature(tools, projectId, sessionId);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Handle a transient error (network failure, usage limit) during feature processing.
|
|
588
|
+
* Instead of releasing the lock and killing the agent, blocks the agent in
|
|
589
|
+
* "waiting" state — heartbeat keeps running, feature stays in current lane.
|
|
590
|
+
* When the user sends a message, onUnblock fires and auto-agent resumes.
|
|
591
|
+
*/
|
|
592
|
+
handleFeatureTransientError(featureId: number, errorMessage: string): void {
|
|
593
|
+
log.warn(`[auto-agent] transient error on feature ${featureId}: ${errorMessage}`);
|
|
594
|
+
log.info("[auto-agent] blocking agent (waiting for user) — lock kept, heartbeat continues");
|
|
595
|
+
|
|
596
|
+
// Transition to waiting with error context. Overlay logic mirrors block()
|
|
597
|
+
// via the shared setOverlayWaitingForResponse() helper.
|
|
598
|
+
if (this.state !== "working") return;
|
|
599
|
+
this.state = "waiting";
|
|
600
|
+
this.setOverlayWaitingForResponse();
|
|
601
|
+
this.persist();
|
|
602
|
+
log.info(`[auto-agent] blocked after transient error (feature ${this.currentFeatureId})`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Handle a fatal error during feature processing: release lock, move back, report.
|
|
607
|
+
* Used for non-transient errors where the agent cannot continue.
|
|
608
|
+
*/
|
|
609
|
+
handleFeatureError(
|
|
610
|
+
tools: KanbanTools,
|
|
611
|
+
featureId: number,
|
|
612
|
+
sessionId: string,
|
|
613
|
+
originalLane: Lane,
|
|
614
|
+
errorMessage: string,
|
|
615
|
+
): void {
|
|
616
|
+
log.error(`[auto-agent] error on feature ${featureId}: ${errorMessage}`, NO_ERROR);
|
|
617
|
+
|
|
618
|
+
// Move back to original lane
|
|
619
|
+
tools.kanbanMove({
|
|
620
|
+
featureId,
|
|
621
|
+
toLane: originalLane,
|
|
622
|
+
changedBy: `agent:${sessionId}`,
|
|
623
|
+
note: `Error: ${errorMessage}`,
|
|
624
|
+
});
|
|
625
|
+
tools.kanbanRelease({ featureId });
|
|
626
|
+
|
|
627
|
+
this.error(errorMessage);
|
|
628
|
+
}
|
|
629
|
+
}
|