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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/agents/fy-design-reviewer.md +77 -0
  4. package/agents/fy-feature-verifier.md +24 -0
  5. package/agents/fy-general-reviewer.md +81 -0
  6. package/agents/fy-guidelines-reviewer.md +46 -0
  7. package/agents/fy-implementer.md +20 -0
  8. package/agents/fy-performance-reviewer.md +46 -0
  9. package/agents/fy-plan-reviewer.md +74 -0
  10. package/agents/fy-plan-verifier.md +44 -0
  11. package/agents/fy-quality-reviewer.md +50 -0
  12. package/agents/fy-requirements-reviewer.md +46 -0
  13. package/agents/fy-researcher.md +6 -0
  14. package/agents/fy-security-reviewer.md +46 -0
  15. package/agents/fy-task-verifier.md +42 -0
  16. package/agents/fy-testing-reviewer.md +60 -0
  17. package/assets/images/kanban-board.png +0 -0
  18. package/assets/images/settings-dropdown.png +0 -0
  19. package/assets/images/settings.png +0 -0
  20. package/assets/images/widget.png +0 -0
  21. package/docs/CONFIGURATION.md +266 -0
  22. package/index.ts +8 -0
  23. package/package.json +85 -0
  24. package/skills/fy-design/SKILL.md +108 -0
  25. package/skills/fy-design-review/SKILL.md +45 -0
  26. package/skills/fy-finish/SKILL.md +28 -0
  27. package/skills/fy-implement/SKILL.md +36 -0
  28. package/skills/fy-plan/SKILL.md +77 -0
  29. package/skills/fy-plan-review/SKILL.md +45 -0
  30. package/skills/fy-research/SKILL.md +38 -0
  31. package/skills/fy-review/SKILL.md +88 -0
  32. package/skills/fy-verify/SKILL.md +23 -0
  33. package/src/commands/auto-agent-commands.ts +748 -0
  34. package/src/commands/kanban-commands.ts +278 -0
  35. package/src/commands/workflow-commands.ts +531 -0
  36. package/src/compaction/compact-handler.ts +371 -0
  37. package/src/compaction/compact-message.ts +52 -0
  38. package/src/compaction/compact-trigger.ts +121 -0
  39. package/src/compaction/safe-editor-write.ts +18 -0
  40. package/src/events/agent/agent-end.ts +19 -0
  41. package/src/events/agent/agent-settled.ts +20 -0
  42. package/src/events/agent/agent-start.ts +18 -0
  43. package/src/events/agent/turn-end.ts +20 -0
  44. package/src/events/agent/turn-start.ts +19 -0
  45. package/src/events/event-deps.ts +33 -0
  46. package/src/events/index.ts +91 -0
  47. package/src/events/input/input.ts +143 -0
  48. package/src/events/session/context.ts +68 -0
  49. package/src/events/session/model-select.ts +20 -0
  50. package/src/events/session/session-compact.ts +20 -0
  51. package/src/events/session/session-shutdown.ts +26 -0
  52. package/src/events/session/session-start.ts +45 -0
  53. package/src/events/session/session-tree.ts +17 -0
  54. package/src/events/tool/tool-call.ts +58 -0
  55. package/src/events/tool/tool-result.ts +54 -0
  56. package/src/git/git-queries.ts +154 -0
  57. package/src/git/resolve-base-branch.ts +173 -0
  58. package/src/git/worktrees/worktree-helpers.ts +142 -0
  59. package/src/git/worktrees/worktree-interception.ts +66 -0
  60. package/src/git/worktrees/worktree-lifecycle.ts +380 -0
  61. package/src/guardrails/completion-gating.ts +63 -0
  62. package/src/guardrails/file-classifier.ts +461 -0
  63. package/src/guardrails/force-add-guard.ts +312 -0
  64. package/src/guardrails/guardrail-tracker.ts +73 -0
  65. package/src/guardrails/guardrail-violations.ts +47 -0
  66. package/src/guardrails/guardrails-engine.ts +663 -0
  67. package/src/guardrails/session-guardrails.ts +75 -0
  68. package/src/guardrails/shell-decompose.ts +285 -0
  69. package/src/guardrails/tdd-enforcement.ts +122 -0
  70. package/src/guardrails/test-output.ts +272 -0
  71. package/src/index.ts +598 -0
  72. package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
  73. package/src/integrations/subagent-integration.ts +166 -0
  74. package/src/integrations/todo-integration.ts +58 -0
  75. package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
  76. package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
  77. package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
  78. package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
  79. package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
  80. package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
  81. package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
  82. package/src/kanban/data/kanban-database.ts +851 -0
  83. package/src/kanban/data/kanban-db-schema.ts +137 -0
  84. package/src/kanban/data/kanban-detect-project.ts +43 -0
  85. package/src/kanban/data/kanban-move-feature.ts +43 -0
  86. package/src/kanban/data/kanban-types.ts +64 -0
  87. package/src/kanban/ensure-feature.ts +226 -0
  88. package/src/kanban/kanban-board-ui/app.js +1125 -0
  89. package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
  90. package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
  91. package/src/kanban/kanban-board-ui/index.html +127 -0
  92. package/src/kanban/kanban-board-ui/style.css +820 -0
  93. package/src/kanban/kanban-bridge.ts +216 -0
  94. package/src/kanban/kanban-context.ts +43 -0
  95. package/src/kanban/kanban-events.ts +34 -0
  96. package/src/kanban/kanban-generate-title.ts +259 -0
  97. package/src/kanban/kanban-generate-topic.ts +170 -0
  98. package/src/kanban/kanban-operations.ts +170 -0
  99. package/src/kanban/kanban-server.ts +931 -0
  100. package/src/kanban/kanban-turn-handlers.ts +55 -0
  101. package/src/kanban/model-capture.ts +34 -0
  102. package/src/log.ts +47 -0
  103. package/src/phases/env-sync.ts +64 -0
  104. package/src/phases/execution-mode.ts +79 -0
  105. package/src/phases/implement-to-verify.ts +31 -0
  106. package/src/phases/phase-progression.ts +249 -0
  107. package/src/phases/phase-transitions.ts +226 -0
  108. package/src/phases/workflow-router.ts +98 -0
  109. package/src/prompts/finish-instructions.ts +143 -0
  110. package/src/prompts/skill-block-builder.ts +73 -0
  111. package/src/prompts/skill-expansion.ts +262 -0
  112. package/src/prompts/task-gate-skill.ts +83 -0
  113. package/src/prompts/template-engine.ts +433 -0
  114. package/src/prompts/text-blocks.ts +279 -0
  115. package/src/review/review-context.ts +56 -0
  116. package/src/review/review-counter.ts +66 -0
  117. package/src/review/review-empty-loop-tracking.ts +52 -0
  118. package/src/review/review-loops.ts +217 -0
  119. package/src/review/review-report.ts +101 -0
  120. package/src/settings/model-overrides.ts +269 -0
  121. package/src/settings/settings-schema.ts +458 -0
  122. package/src/settings/settings-types.ts +81 -0
  123. package/src/settings/settings-ui.ts +222 -0
  124. package/src/shared/errors.ts +24 -0
  125. package/src/shared/global.d.ts +22 -0
  126. package/src/shared/session-entries.ts +24 -0
  127. package/src/shared/types.ts +195 -0
  128. package/src/shared/workflow-refs.ts +253 -0
  129. package/src/shared/workflow-types.ts +180 -0
  130. package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
  131. package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
  132. package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
  133. package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
  134. package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
  135. package/src/state/archive-artifacts.ts +602 -0
  136. package/src/state/artifact-junction.ts +462 -0
  137. package/src/state/artifact-paths.ts +230 -0
  138. package/src/state/feature-activation.ts +239 -0
  139. package/src/state/feature-management.ts +325 -0
  140. package/src/state/feature-record-store.ts +39 -0
  141. package/src/state/feature-session.ts +263 -0
  142. package/src/state/feature-state.ts +439 -0
  143. package/src/state/post-turn-dispatch.ts +106 -0
  144. package/src/state/session-lifecycle.ts +346 -0
  145. package/src/state/state-persistence.ts +122 -0
  146. package/src/state/worth-notes.ts +89 -0
  147. package/src/tools/add-to-backlog.ts +117 -0
  148. package/src/tools/phase-ready.ts +742 -0
  149. package/src/tools/task-ready-advance.ts +235 -0
  150. package/src/tools/text-result.ts +15 -0
  151. package/src/ui/featyard-widget.ts +250 -0
  152. package/src/ui/manage-features-dialog.ts +246 -0
  153. 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
+ }