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,748 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Register auto-agent commands (/fy:auto-agent, /fy:auto-worker, /fy:auto-designer, /fy:auto-pause)
6
+ * + startAutoAgent + _activateFeature + polling/grace-period
7
+ * + onFeatureComplete/onFeatureError/onBlock/onUnblock/onFeatureUatHandoff callbacks.
8
+ */
9
+
10
+ import { basename } from "node:path";
11
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { cleanupStoppedAgents } from "../kanban/auto-agent/auto-agent-cleanup.js";
13
+ import { GracePeriodManager } from "../kanban/auto-agent/auto-agent-grace-period.js";
14
+ import {
15
+ activateFeature as _activateFeature,
16
+ onFeatureComplete as _onFeatureComplete,
17
+ type OnFeatureCompleteDeps,
18
+ } from "../kanban/auto-agent/auto-agent-lifecycle.js";
19
+ import {
20
+ type AutoAgentRole,
21
+ AutoAgentStateMachine,
22
+ startAgentHeartbeat,
23
+ } from "../kanban/auto-agent/auto-agent-state-machine.js";
24
+ import { interactiveSessionIdFor, normalizeRepoPath } from "../kanban/data/kanban-database.js";
25
+ import type { Feature } from "../kanban/data/kanban-types.js";
26
+ import { DEFAULT_NOTIFY_LEVEL } from "../kanban/kanban-bridge.js";
27
+ import type { KanbanContext } from "../kanban/kanban-context.js";
28
+ import { log, NO_ERROR } from "../log.js";
29
+ import { getSettings } from "../settings/settings-ui.js";
30
+
31
+ /** Remove stopped/error agent reference so it doesn't block new agents. */
32
+ export async function registerAutoAgent(pi: ExtensionAPI, ctx: KanbanContext): Promise<void> {
33
+ const { getDatabase, getTools, notify, requestWidgetUpdate } = ctx;
34
+
35
+ /** Resolve the project for the current working directory, creating one if needed. */
36
+ async function resolveProject(
37
+ database: Awaited<ReturnType<typeof getDatabase>>,
38
+ ): Promise<{ id: number; name: string } | null> {
39
+ let cwd = process.cwd();
40
+ try {
41
+ const { resolveRepoPath } = await import("../kanban/data/kanban-detect-project.js");
42
+ const resolved = await resolveRepoPath(process.cwd());
43
+ log.info(`[kanban] resolveProject: git resolved repoPath="${resolved}", process.cwd()="${process.cwd()}"`);
44
+ cwd = resolved;
45
+ } catch (err) {
46
+ log.warn(`[kanban] resolveProject: git resolution failed, using process.cwd()="${process.cwd()}": ${err}`);
47
+ }
48
+ let project = database.findProjectByRepoPath(cwd);
49
+ if (!project) {
50
+ const allProjects = database.listProjects();
51
+ const targetBasename = basename(normalizeRepoPath(cwd));
52
+ for (const p of allProjects) {
53
+ if (!p.repo_path) continue;
54
+ const storedBasename = basename(normalizeRepoPath(p.repo_path));
55
+ if (storedBasename === targetBasename) {
56
+ log.info(
57
+ `[kanban] resolveProject: matched existing project ${p.id} "${p.name}" by basename (stored="${p.repo_path}", target="${cwd}")`,
58
+ );
59
+ project = p;
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ if (!project) {
65
+ log.info(
66
+ `[kanban] resolveProject: no project found for repoPath="${cwd}", auto-creating with name="${basename(cwd)}"`,
67
+ );
68
+ const newProjectId = database.createProject({ name: basename(cwd), repoPath: cwd });
69
+ project = database.getProject(newProjectId);
70
+ }
71
+ if (project) {
72
+ log.info(`[kanban] resolveProject: project ${project.id} "${project.name}" for repoPath="${cwd}"`);
73
+ }
74
+ return project;
75
+ }
76
+
77
+ /** True if the agent exists and is in a mutable (non-terminal) state.
78
+ *
79
+ * NOTE: distinct from the `isActive()` auto-agent callback (below). `isAgentLive`
80
+ * answers "can this SM be mutated in place (role switch)?" — it is true for every
81
+ * state except `stopped`/`error`, including `idle`. `isActive()` answers the
82
+ * external "is auto-mode currently on?" question for skill-expansion / phase-ready
83
+ * and excludes `idle`/`error`/`stopped`. The `idle` divergence is unreachable in
84
+ * practice: an SM reaches the bridge only after `start()` succeeds (`idle`→
85
+ * `working`), so a bridge agent is never `idle`. Kept as two helpers because they
86
+ * serve genuinely different callers; do not collapse them without auditing both. */
87
+ function isAgentLive(agent: AutoAgentStateMachine | null | undefined): agent is AutoAgentStateMachine {
88
+ return !!agent && agent.getState() !== "stopped" && agent.getState() !== "error";
89
+ }
90
+
91
+ /** The configured wait-timeout duration for a role (single source of truth). */
92
+ function waitTimeoutMsFor(role: AutoAgentRole): number | null {
93
+ const settings = getSettings();
94
+ return role === "worker" ? settings.autoWorkerWaitTimeoutMs : settings.autoDesignerWaitTimeoutMs;
95
+ }
96
+
97
+ /** Try to resume a paused agent of the same role+project. Returns true if resumed. */
98
+ function tryResumePausedAgent(role: AutoAgentRole, projectId: number, agentCtx: ExtensionCommandContext): boolean {
99
+ const current = globalThis.__piKanban?.autoAgent;
100
+ if (current && current.getRole() === role && current.projectId === projectId && current.getState() === "paused") {
101
+ current.unpause();
102
+ globalThis.__piCtx?.refresh(agentCtx);
103
+ const pollingFn = current.getStartPollingFn();
104
+ if (pollingFn) pollingFn();
105
+ agentCtx.ui.notify(`Auto-${role} resumed`, "info");
106
+ requestWidgetUpdate();
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ /** Check if a worker is already running for the given project. Returns error message or null. */
113
+ function checkWorkerNotRunning(projectId: number): string | null {
114
+ const current = globalThis.__piKanban?.autoAgent;
115
+ if (current && current.getRole() === "worker" && current.projectId === projectId && isAgentLive(current)) {
116
+ return "An auto-worker is already running for this project. Pause it with /fy:auto-pause first.";
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /** Try to match current session's active slug to a kanban feature. Returns true if matched. */
122
+ async function tryMatchSessionSlug(
123
+ sm: AutoAgentStateMachine,
124
+ projectId: number,
125
+ sessionId: string,
126
+ agentCtx: ExtensionCommandContext,
127
+ ): Promise<boolean> {
128
+ try {
129
+ const { getActiveFeatureSlug } = await import("../index.js");
130
+ const activeSlug = process.env.PI_FY_FEATURE ?? getActiveFeatureSlug();
131
+ if (!activeSlug) return false;
132
+ const database = await getDatabase();
133
+ const feature = database.findFeatureBySlug(activeSlug, projectId);
134
+ if (!feature || !sm.getLanes().includes(feature.lane)) return false;
135
+ // Handle existing lock
136
+ if (feature.locked_at) {
137
+ if (feature.locked_by_session === interactiveSessionIdFor(activeSlug)) {
138
+ database.unlockFeature(feature.id);
139
+ } else {
140
+ agentCtx.ui.notify(
141
+ `Feature ${activeSlug} is locked by another session. Release the lock on the kanban board first.`,
142
+ "warning",
143
+ );
144
+ }
145
+ }
146
+ const locked = database.lockFeature(feature.id, sessionId);
147
+ if (locked) {
148
+ sm.adoptFeature(feature.id, feature.lane);
149
+ database.updateFeature({ featureId: feature.id, assignedSession: sessionId });
150
+ startAgentHeartbeat(sm, feature.id, getTools);
151
+ log.info(`[kanban] tryMatchSessionSlug: matched active slug "${activeSlug}" to kanban feature ${feature.id}`);
152
+ agentCtx.ui.notify(`Auto-${sm.getRole()} locked existing feature: "${feature.title}"`, "info");
153
+ requestWidgetUpdate();
154
+ return true;
155
+ }
156
+ } catch (err) {
157
+ log.warn(`[kanban] tryMatchSessionSlug: session-slug matching failed, falling back to normal pick: ${err}`);
158
+ }
159
+ return false;
160
+ }
161
+
162
+ async function startAutoAgent(role: AutoAgentRole, agentCtx: ExtensionCommandContext): Promise<void> {
163
+ const database = await getDatabase();
164
+ const project = await resolveProject(database);
165
+ if (!project) {
166
+ agentCtx.ui.notify("Failed to create project. Please try again.", "error");
167
+ return;
168
+ }
169
+ const projectId = project.id;
170
+ const sessionId = crypto.randomUUID();
171
+
172
+ // Pause-resume: if a paused agent of the same role+project exists, unpause it
173
+ if (tryResumePausedAgent(role, projectId, agentCtx)) return;
174
+
175
+ // Check if the same role is already running — no-op
176
+ const currentAgent = globalThis.__piKanban?.autoAgent;
177
+ if (currentAgent && currentAgent.getRole() === role && isAgentLive(currentAgent)) {
178
+ agentCtx.ui.notify(`Auto-${role} already running`, "warning");
179
+ return;
180
+ }
181
+
182
+ // Single-worker enforcement (scoped per project)
183
+ if (role === "worker") {
184
+ const workerError = checkWorkerNotRunning(projectId);
185
+ if (workerError) {
186
+ agentCtx.ui.notify(workerError, "error");
187
+ return;
188
+ }
189
+ }
190
+
191
+ // Role switch: a different-role agent is already running. Mutate its role
192
+ // in place instead of tearing it down and recreating it. This preserves the
193
+ // current feature, its lock (same sessionId UUID), all timers, and the
194
+ // callback/overlay wiring — so the user's in-flight feature is not orphaned
195
+ // or double-locked. The next feature pick uses the new role's lanes.
196
+ if (currentAgent && currentAgent.getRole() !== role && isAgentLive(currentAgent)) {
197
+ const wasPaused = currentAgent.getState() === "paused";
198
+ globalThis.__piCtx?.refresh(agentCtx);
199
+ currentAgent.setRole(role);
200
+ // Refresh the wait-timeout duration to the new role's configured value
201
+ // (the onTimeout callback itself is role-agnostic: it reads sm.getRole() at fire time).
202
+ currentAgent.setWaitTimeoutMs(waitTimeoutMsFor(role));
203
+ if (wasPaused) {
204
+ // A paused agent is resumed by re-running a role command (mirrors the
205
+ // same-role resume path, tryResumePausedAgent). Switching its role resumes
206
+ // it too, so one command yields a running agent of the new role (rather
207
+ // than leaving it paused and forcing a second command to resume).
208
+ currentAgent.unpause();
209
+ const pollingFn = currentAgent.getStartPollingFn();
210
+ if (pollingFn) pollingFn();
211
+ agentCtx.ui.notify(`Auto-${role} resumed`, "info");
212
+ } else {
213
+ agentCtx.ui.notify(`Auto-${role} started`, "info");
214
+ }
215
+ requestWidgetUpdate();
216
+ return;
217
+ }
218
+
219
+ const sm = new AutoAgentStateMachine(role, projectId, sessionId);
220
+ if (!sm.start()) {
221
+ agentCtx.ui.notify(`Auto-${role} already running`, "warning");
222
+ return;
223
+ }
224
+ // Stash session functions from the command handler agentCtx so the initial
225
+ // feature pick (and subsequent polling) can use them.
226
+ globalThis.__piCtx?.refresh(agentCtx);
227
+ // Stash notify for timer callbacks (polling, grace period) — handled by PiCtx
228
+ // Register terminal input listener for activity detection (persists across sessions)
229
+ const unsubscribeInput = agentCtx.ui.onTerminalInput(() => {
230
+ const gpm = globalThis.__piKanban?.gracePeriod as GracePeriodManager | undefined;
231
+ gpm?.onUserActivity();
232
+ });
233
+ if (globalThis.__piKanban) globalThis.__piKanban.terminalInputUnsubscribe = unsubscribeInput;
234
+ if (globalThis.__piKanban) globalThis.__piKanban.autoAgent = sm;
235
+
236
+ agentCtx.ui.notify(`Auto-${role} started`, "info");
237
+
238
+ // Wire overlay callback for waiting-for-response status
239
+ // (must be before session-slug matching so it's available on early return)
240
+ sm.setOverlayCallback((featureId, status) => {
241
+ try {
242
+ // Access database synchronously via the bridge (already initialized by this point)
243
+ const db = globalThis.__piKanban?.database;
244
+ if (!db) return;
245
+ if (status) {
246
+ db.setOverlayStatus(featureId, status);
247
+ } else {
248
+ db.clearOverlayStatus(featureId);
249
+ }
250
+ } catch (err) {
251
+ log.warn(`[kanban] overlay status update failed: ${err}`);
252
+ }
253
+ });
254
+
255
+ /**
256
+ * Thin wrapper around the extracted activateFeature function.
257
+ * Binds closure-captured dependencies to the extracted module-level function.
258
+ */
259
+ const activateFeature = async (
260
+ result: { feature: Feature; skill: string; kanbanFeatureId: number },
261
+ piCtx: { sendUserMessage: (msg: string, options?: { deliverAs?: "steer" | "followUp" }) => void },
262
+ ): Promise<void> => {
263
+ return _activateFeature(result, piCtx, {
264
+ database,
265
+ sm,
266
+ getTools,
267
+ activateWorkflowForFeature: ctx.activateWorkflowForFeature,
268
+ resumeWorkflowForFeature: ctx.resumeWorkflowForFeature,
269
+ setWorkflowInitiatedNewSession: ctx.setWorkflowInitiatedNewSession,
270
+ });
271
+ };
272
+ // Store on globalThis so callbacks registered by previous module instances
273
+ // (before extension reload) can still call it. Without this, the closure
274
+ // reference breaks when jiti reloads the module via ctx.newSession().
275
+ if (globalThis.__piKanban) globalThis.__piKanban.activateFeature = activateFeature;
276
+
277
+ // Session-slug matching: if the current session already has an active feature,
278
+ // look it up in kanban and lock it instead of picking a new one.
279
+ if (await tryMatchSessionSlug(sm, projectId, sessionId, agentCtx)) return;
280
+
281
+ // Wire wait timeout and autoOnBlock behavior. getSettings() is the cached,
282
+ // in-memory accessor (same source as waitTimeoutMsFor above) — avoids the
283
+ // 2x sync readFileSync+parse that loadSettingsFromFiles would do here.
284
+ const settings = getSettings();
285
+ const timeoutMs = waitTimeoutMsFor(role);
286
+ const autoOnBlock = settings.autoOnBlock;
287
+ const onWaitTimeout: (featureId: number, reason: "timeout") => void = (featureId, _reason) => {
288
+ if (autoOnBlock === "wait") {
289
+ // Keep waiting — restart the timeout
290
+ sm.startWaitTimeoutForFeature(featureId);
291
+ log.info("[kanban] auto-agent wait timeout expired (autoOnBlock=wait), restarting timer");
292
+ return;
293
+ }
294
+ // autoOnBlock === "switch": release lock, clear overlay, take next feature
295
+ log.info("[kanban] auto-agent wait timeout expired (autoOnBlock=switch), switching to next feature");
296
+ const originalLane = sm.getCurrentFeatureLane() ?? "ready";
297
+ // Use promise chain to handle async operations
298
+ getTools()
299
+ .then(async (tools) => {
300
+ sm.unblock(); // This clears overlay via callback
301
+ tools.kanbanMove({
302
+ featureId,
303
+ toLane: originalLane,
304
+ changedBy: `agent:${sessionId}`,
305
+ note: "Wait timeout expired",
306
+ });
307
+ tools.kanbanRelease({ featureId });
308
+ const next = sm.pickNextFeature(tools, projectId, sessionId);
309
+ if (next) {
310
+ await activateFeature(next, pi);
311
+ }
312
+ })
313
+ .catch((err) => {
314
+ log.warn(`[kanban] auto-agent switch failed: ${err}`);
315
+ sm.error(String(err));
316
+ notify(`❌ Auto-${sm.getRole()} error: ${err}`, "error");
317
+ requestWidgetUpdate();
318
+ });
319
+ };
320
+ // Wait timeout is started after a feature is successfully picked (not here)
321
+
322
+ sm.setWaitTimeoutConfig(timeoutMs, onWaitTimeout);
323
+
324
+ // Polling timer: when no feature is available, retry after autoPollMs
325
+ function startPollingTimer() {
326
+ const pollMs = getSettings().autoPollMs ?? 30_000;
327
+ const timer = setTimeout(async () => {
328
+ if (sm.getState() !== "polling") return;
329
+ log.info(
330
+ `[kanban] auto-agent (${sm.getRole()}) polling timer fired, retrying feature pick (project=${projectId})`,
331
+ );
332
+ try {
333
+ const kanbanTools = await getTools();
334
+
335
+ // Peek first to check lane without locking
336
+ const peeked = kanbanTools.kanbanPeek({ projectId, lanes: sm.getLanes() });
337
+ if (!peeked) {
338
+ // Still no features — keep polling
339
+ startPollingTimer();
340
+ return;
341
+ }
342
+
343
+ // Grace period for design lane features
344
+ // Note: peeked feature may differ from the feature ultimately locked on expire.
345
+ // kanbanPeek is read-only; pickNextFeature on expire does its own search.
346
+ if (peeked.lane === "design") {
347
+ const gpm = createGracePeriodManager(sm, kanbanTools);
348
+ gpm.start();
349
+ sm.enterGracePeriod(); // after GPM created — avoids stuck state if constructor fails
350
+ notify(
351
+ `🔄 Auto-${sm.getRole()} found new feature in design lane. Starting in 30s...`,
352
+ DEFAULT_NOTIFY_LEVEL,
353
+ );
354
+ return;
355
+ }
356
+
357
+ // Non-design lane: immediate activation (existing behavior)
358
+ sm.featureFound();
359
+ const result = sm.pickNextFeature(kanbanTools, projectId, sessionId);
360
+ if (result) {
361
+ log.info(
362
+ `[kanban] auto-agent (${sm.getRole()}) polling: picked feature "${result.feature.title}" (id=${result.kanbanFeatureId})`,
363
+ );
364
+ notify(`🔄 Auto-${sm.getRole()} found new feature. Activating...`, DEFAULT_NOTIFY_LEVEL);
365
+ await activateFeature(result, pi);
366
+ } else {
367
+ // Still no feature — keep polling
368
+ log.info(`[kanban] auto-agent (${sm.getRole()}) polling: still no features, scheduling next poll`);
369
+ startPollingTimer();
370
+ }
371
+ } catch (err) {
372
+ log.error(`[kanban] auto-agent (${sm.getRole()}) polling pick failed (project=${projectId})`, err);
373
+ sm.error(String(err));
374
+ notify(`❌ Auto-${sm.getRole()} error: ${err}`, "error");
375
+ requestWidgetUpdate();
376
+ }
377
+ }, pollMs);
378
+ // Track timer for cleanup on stop
379
+ sm.setPollingTimer(timer);
380
+ }
381
+
382
+ function createGracePeriodManager(
383
+ sm: AutoAgentStateMachine,
384
+ kanbanTools: import("../kanban/kanban-operations.js").KanbanTools,
385
+ ): GracePeriodManager {
386
+ // Stop any previous GPM for this agent (defensive — shouldn't happen in normal flow)
387
+ const prevGpm = globalThis.__piKanban?.gracePeriod as { stop?: () => void } | undefined;
388
+ if (prevGpm) prevGpm.stop?.();
389
+
390
+ const gpm = new GracePeriodManager(
391
+ // onExpired
392
+ async () => {
393
+ // Guard: if agent left grace-period state (error, stop, pause), don't activate
394
+ if (sm.getState() !== "grace-period") return;
395
+ sm.exitGracePeriod();
396
+ const projectId = sm.projectId;
397
+ if (projectId === undefined) return;
398
+ const result = sm.pickNextFeature(kanbanTools, projectId, sm.sessionId);
399
+ if (result) {
400
+ notify("⏰ Grace period ended. Activating next feature...", DEFAULT_NOTIFY_LEVEL);
401
+ await activateFeature(result, pi);
402
+ } else {
403
+ // pickNextFeature already transitioned to polling via noFeatureAvailable()
404
+ startPollingTimer();
405
+ }
406
+ },
407
+ // onTick
408
+ () => {
409
+ const requestUpdate = globalThis.__piWorkflowMonitor?.requestWidgetUpdate;
410
+ requestUpdate?.();
411
+ },
412
+ // onEnd
413
+ () => {
414
+ if (globalThis.__piKanban?.gracePeriod === gpm) {
415
+ if (globalThis.__piKanban) globalThis.__piKanban.gracePeriod = undefined;
416
+ }
417
+ },
418
+ { durationMs: 30_000 },
419
+ );
420
+ if (globalThis.__piKanban) globalThis.__piKanban.gracePeriod = gpm;
421
+ return gpm;
422
+ }
423
+
424
+ // Expose on globalThis so onFeatureComplete callback (at kanbanExtension top level)
425
+ // can create grace period managers. Same pattern as globalThis.__piKanban.activateFeature.
426
+ if (globalThis.__piKanban) globalThis.__piKanban.createGracePeriodManager = createGracePeriodManager;
427
+
428
+ // Register polling function on state machine so onFeatureComplete can access it
429
+ sm.setStartPollingFn(startPollingTimer);
430
+
431
+ // Kick off the first feature pick
432
+ try {
433
+ log.info(
434
+ `[kanban] auto-agent (${sm.getRole()}): starting initial feature pick for project ${projectId}, lanes: [${sm.getLanes().join(", ")}]`,
435
+ );
436
+ const kanbanTools = await getTools();
437
+ const result = sm.pickNextFeature(kanbanTools, projectId, sessionId);
438
+ if (result) {
439
+ log.info(
440
+ `[kanban] auto-agent (${sm.getRole()}): picked feature "${result.feature.title}" (id=${result.kanbanFeatureId}, slug="${result.feature.slug}", lane="${result.feature.lane}", description=${result.feature.description ? "present" : "MISSING"})`,
441
+ );
442
+ await activateFeature(result, pi);
443
+ } else {
444
+ // No feature available — pickNextFeature already transitioned to polling
445
+ startPollingTimer();
446
+ requestWidgetUpdate();
447
+ const pollMs = getSettings().autoPollMs ?? 30_000;
448
+ log.info(
449
+ `[kanban] auto-agent (${sm.getRole()}): no features available at startup (project=${projectId}, lanes=[${sm.getLanes().join(", ")}]), polling every ${pollMs}ms`,
450
+ );
451
+ }
452
+ } catch (err) {
453
+ log.error(`[kanban] auto-agent (${sm.getRole()}) initial pick failed (project=${projectId})`, err);
454
+ sm.error(String(err));
455
+ notify(`❌ Auto-${sm.getRole()} error: ${err}`, "error");
456
+ requestWidgetUpdate();
457
+ }
458
+ }
459
+
460
+ pi.registerCommand("fy:auto-agent", {
461
+ description: "Start autonomous loop: picks from design and ready lanes",
462
+ async handler(_args, cmdCtx) {
463
+ await startAutoAgent("agent", cmdCtx);
464
+ },
465
+ });
466
+
467
+ pi.registerCommand("fy:auto-worker", {
468
+ description: "Start autonomous loop: picks from ready lane only",
469
+ async handler(_args, cmdCtx) {
470
+ await startAutoAgent("worker", cmdCtx);
471
+ },
472
+ });
473
+
474
+ pi.registerCommand("fy:auto-designer", {
475
+ description: "Start autonomous loop: picks from design lane only",
476
+ async handler(_args, cmdCtx) {
477
+ await startAutoAgent("designer", cmdCtx);
478
+ },
479
+ });
480
+
481
+ pi.registerCommand("fy:auto-pause", {
482
+ description: "Pause auto-loop (keeps current feature, heartbeat alive)",
483
+ async handler(_args, cmdCtx) {
484
+ const current = globalThis.__piKanban?.autoAgent;
485
+ if (!current || current.getState() === "stopped" || current.getState() === "error") {
486
+ cmdCtx.ui.notify("No auto-agent running", "warning");
487
+ return;
488
+ }
489
+ // Resolve current project to scope pause to this project only
490
+ try {
491
+ const db = await getDatabase();
492
+ let cwd = process.cwd();
493
+ try {
494
+ const { resolveRepoPath } = await import("../kanban/data/kanban-detect-project.js");
495
+ cwd = await resolveRepoPath(process.cwd());
496
+ } catch (err) {
497
+ log.warn(`[kanban] auto-pause: git resolution failed, using process.cwd(): ${err}`);
498
+ }
499
+ const project = db.findProjectByRepoPath(cwd);
500
+ if (project && current.projectId !== project.id) {
501
+ cmdCtx.ui.notify("No auto-agent running for this project", "warning");
502
+ return;
503
+ }
504
+ } catch (err) {
505
+ log.warn(`[kanban] auto-pause: database unavailable, pausing agent anyway: ${err}`);
506
+ }
507
+ current.pause();
508
+ // Stop grace period manager if active
509
+ const gpm = globalThis.__piKanban?.gracePeriod as GracePeriodManager | undefined;
510
+ if (gpm) {
511
+ gpm.stop();
512
+ notify("Auto-agent paused. Grace period cancelled.", DEFAULT_NOTIFY_LEVEL);
513
+ }
514
+ cmdCtx.ui.notify("Auto-loop paused (heartbeat stays, no auto-next-feature)", "info");
515
+ requestWidgetUpdate();
516
+ },
517
+ });
518
+
519
+ // Wire cross-extension callbacks so workflow-monitor can notify kanban
520
+ // Auto-agent callback: workflow-monitor calls this on feature completion
521
+ const { setAutoAgentCallback } = await import("../kanban/auto-agent/auto-agent-state-machine.js");
522
+ const onFCtx: OnFeatureCompleteDeps = {
523
+ getDatabase,
524
+ getTools,
525
+ notify,
526
+ pi,
527
+ activateWorkflowForFeature: ctx.activateWorkflowForFeature,
528
+ resumeWorkflowForFeature: ctx.resumeWorkflowForFeature,
529
+ setWorkflowInitiatedNewSession: ctx.setWorkflowInitiatedNewSession,
530
+ };
531
+ setAutoAgentCallback({
532
+ async onFeatureComplete(slug: string): Promise<void> {
533
+ return _onFeatureComplete(slug, onFCtx);
534
+ },
535
+ async onFeatureError(slug: string, error: string): Promise<void> {
536
+ log.warn(`[kanban] onFeatureError called for slug "${slug}": ${error}`);
537
+ const { findAgentForSlug } = await import("./kanban-commands.js");
538
+ const current = globalThis.__piKanban?.autoAgent;
539
+ const match = current
540
+ ? await findAgentForSlug(slug, current, getDatabase, { state: ["working", "paused"] })
541
+ : null;
542
+ if (match) {
543
+ const { sm, featureId } = match;
544
+ log.warn(`[kanban] auto-agent error on ${slug}: ${error}`);
545
+ sm.handleFeatureTransientError(featureId, error);
546
+ requestWidgetUpdate();
547
+ } else {
548
+ log.warn(`[kanban] onFeatureError: no agent found for slug "${slug}"`);
549
+ }
550
+ cleanupStoppedAgents();
551
+ },
552
+ async onBlock(slug: string): Promise<void> {
553
+ const { findAgentForSlug } = await import("./kanban-commands.js");
554
+ const current = globalThis.__piKanban?.autoAgent;
555
+ const match = current ? await findAgentForSlug(slug, current, getDatabase, { state: "working" }) : null;
556
+ if (!match) {
557
+ log.info(`[kanban] onBlock: no working agent found for slug "${slug}"`);
558
+ return;
559
+ }
560
+ match.sm.block();
561
+ log.info(`[kanban] auto-agent blocked on ${slug}, waiting for user input`);
562
+ requestWidgetUpdate();
563
+ },
564
+ async onUnblock(slug: string): Promise<void> {
565
+ const { findResumableAgent } = await import("./kanban-commands.js");
566
+ const current = globalThis.__piKanban?.autoAgent;
567
+ const match = current ? await findResumableAgent(slug, current, getDatabase) : null;
568
+ if (!match) {
569
+ log.info(`[kanban] onUnblock: no waiting agent found for slug "${slug}"`);
570
+ return;
571
+ }
572
+ match.sm.unblock();
573
+ log.info(`[kanban] auto-agent unblocked on ${slug}, resuming work`);
574
+ requestWidgetUpdate();
575
+ },
576
+ async onFeatureUatHandoff(slug: string): Promise<void> {
577
+ log.info(`[kanban] onFeatureUatHandoff called for slug "${slug}"`);
578
+ const current = globalThis.__piKanban?.autoAgent;
579
+ if (current) {
580
+ log.info(
581
+ `[kanban] onFeatureUatHandoff: agent state=${current.getState()}, featureId=${current.getCurrentFeatureId()}, role=${current.getRole()}`,
582
+ );
583
+ }
584
+ const { findAnyActiveAgent } = await import("./kanban-commands.js");
585
+ const match = current ? await findAnyActiveAgent(slug, current, getDatabase) : null;
586
+ log.info(`[kanban] onFeatureUatHandoff: findAnyActiveAgent result=${match ? "found" : "not found"}`);
587
+ if (!match) {
588
+ log.warn(`[kanban] onFeatureUatHandoff: no agent found for slug "${slug}" — feature will stay locked`);
589
+ return;
590
+ }
591
+ if (match.sm.projectId === undefined) {
592
+ log.warn(`[kanban] onFeatureUatHandoff: agent for slug "${slug}" has undefined projectId`);
593
+ return;
594
+ }
595
+ const { sm, featureId } = match;
596
+ log.info(
597
+ `[kanban] onFeatureUatHandoff: found agent for slug "${slug}", featureId=${featureId}, role=${sm.getRole()}`,
598
+ );
599
+
600
+ try {
601
+ const tools = await getTools();
602
+ const projectId = sm.projectId;
603
+ if (projectId === undefined) {
604
+ log.warn("[kanban] UAT handoff: state machine has no projectId");
605
+ return;
606
+ }
607
+ const nextFeature = sm.handleFeatureUatHandoff(tools, featureId, sm.sessionId, projectId);
608
+ if (nextFeature) {
609
+ log.info(
610
+ `[kanban] onFeatureUatHandoff: picked next feature "${nextFeature.feature.title}" (id=${nextFeature.kanbanFeatureId})`,
611
+ );
612
+ const doActivate = globalThis.__piKanban?.activateFeature;
613
+ if (!doActivate) {
614
+ log.error("[kanban] activateFeature not available on __piKanban bridge", NO_ERROR);
615
+ return;
616
+ }
617
+ await doActivate(nextFeature, pi);
618
+ return;
619
+ }
620
+ log.info(`[kanban] onFeatureUatHandoff: no next feature available after "${slug}"`);
621
+ } catch (err) {
622
+ log.error(`[kanban] onFeatureUatHandoff: handleFeatureUatHandoff failed for slug "${slug}"`, err);
623
+ }
624
+ const pollingFn = sm.getStartPollingFn();
625
+ if (pollingFn) pollingFn();
626
+ log.info("[kanban] auto-agent: no more features available after UAT handoff, polling...");
627
+ cleanupStoppedAgents();
628
+ },
629
+ isActive(): boolean {
630
+ const sm = globalThis.__piKanban?.autoAgent;
631
+ if (!sm) return false;
632
+ const s = sm.getState();
633
+ return s === "working" || s === "polling" || s === "waiting" || s === "paused" || s === "grace-period";
634
+ },
635
+ });
636
+ }
637
+
638
+ // === UAT command (fy:auto-stop) — merged from uat-commands.ts ===
639
+
640
+ /**
641
+ * UAT-related commands.
642
+ *
643
+ * UAT is a collaborative phase: the agent works WITH the user to fix issues in
644
+ * place, and the user advances out of UAT via /fy:next (which routes one step,
645
+ * uat-aware, including terminal completion). This module registers /fy:auto-stop.
646
+ */
647
+
648
+ import type { FeatureSession } from "../state/feature-session.js";
649
+ import { persistState } from "../state/state-persistence.js";
650
+ import { NO_FEATURE_STATE, updateWidget } from "../ui/featyard-widget.js";
651
+
652
+ export interface UatCommandDeps {
653
+ handler: FeatureSession;
654
+ getExpectedSkill: () => string | null;
655
+ getAutoAgentCallback: () => import("../kanban/auto-agent/auto-agent-state-machine.js").AutoAgentCallback | null;
656
+ }
657
+
658
+ export function registerUatCommands(pi: ExtensionAPI, deps: UatCommandDeps): void {
659
+ const { handler } = deps;
660
+
661
+ // --- Continue Interactive Command ---
662
+ // Allows user to manually unblock a conversation that was waiting for auto-agent.
663
+ // Clears any auto-agent waiting state and continues the session interactively.
664
+ pi.registerCommand("fy:auto-stop", {
665
+ description: "Stop the auto-agent and resume interactive control (detaches auto-agent, no skill re-dispatch)",
666
+ async handler(_args, ctx: ExtensionCommandContext) {
667
+ globalThis.__piCtx?.refresh(ctx);
668
+
669
+ const slug = handler.getActiveFeatureSlug();
670
+
671
+ // Stop the auto-agent and hand control back to the user. The feature the
672
+ // agent was working on is NOT released or moved — instead its lock is
673
+ // reassigned from the agent's session UUID to the interactive identity
674
+ // `session:<slug>`. Interactive locks are never swept by cleanupExpiredLocks,
675
+ // so the lock survives indefinitely (user can step away, or pi can crash)
676
+ // until the user releases it on the kanban board or starts an auto-agent in
677
+ // this session, at which point tryMatchSessionSlug reassigns it back to a UUID.
678
+ const current = globalThis.__piKanban?.autoAgent;
679
+ if (current) {
680
+ const featureId = current.getCurrentFeatureId();
681
+ const agentSessionId = current.sessionId;
682
+ // requestStop is immediate: clears heartbeat/polling/wait timers, clears
683
+ // any waiting-for-response overlay, transitions to "stopped".
684
+ current.requestStop();
685
+
686
+ // requestStop does not own the GracePeriodManager (it is stopped externally
687
+ // by its caller — see /fy:auto-pause). If the agent was in grace-period state,
688
+ // stop the GPM too so its setInterval doesn't keep ticking widget updates
689
+ // for up to 30s after the user stopped the agent (requirement: clear timers
690
+ // from any state). Mirrors the /fy:auto-pause GPM stop.
691
+ globalThis.__piKanban?.gracePeriod?.stop();
692
+
693
+ // Reassign the agent-held lock to the interactive identity so it persists
694
+ // without a heartbeat. Use the feature's own slug (source of truth) rather
695
+ // than the session's active slug, so the lock identity always matches the
696
+ // feature — this mirrors how tryMatchSessionSlug looks the feature up by slug
697
+ // and will reassign it back to a UUID when an auto-agent starts here.
698
+ // Each failure path is logged (not silent) so a lost lock is observable.
699
+ if (featureId !== null) {
700
+ const { getDatabaseInstance } = await import("../kanban/kanban-bridge.js");
701
+ const { interactiveSessionIdFor } = await import("../kanban/data/kanban-database.js");
702
+ const db = getDatabaseInstance();
703
+ const feature = db?.getFeature(featureId) ?? null;
704
+ if (!db) {
705
+ log.warn("[auto-stop] kanban database unavailable; feature lock not reassigned");
706
+ } else if (!feature) {
707
+ log.warn(`[auto-stop] feature ${featureId} not found; lock not reassigned`);
708
+ } else if (!feature.slug) {
709
+ // A slugless feature has no stable interactive identity; the lock stays
710
+ // under the agent UUID and will be swept on the next activation.
711
+ log.warn(
712
+ `[auto-stop] feature ${featureId} has no slug; cannot form an interactive lock identity, ` +
713
+ "lock left under the agent and will expire",
714
+ );
715
+ } else {
716
+ const interactiveSession = interactiveSessionIdFor(feature.slug);
717
+ if (db.reassignLock(featureId, agentSessionId, interactiveSession)) {
718
+ db.updateFeature({ featureId, assignedSession: interactiveSession });
719
+ } else {
720
+ log.warn(`[auto-stop] lock on feature ${featureId} not held by agent ${agentSessionId}; not reassigned`);
721
+ }
722
+ }
723
+ }
724
+ }
725
+ const { NO_AUTO_AGENT_CALLBACK, setAutoAgentCallback } = await import(
726
+ "../kanban/auto-agent/auto-agent-state-machine.js"
727
+ );
728
+ setAutoAgentCallback(NO_AUTO_AGENT_CALLBACK);
729
+ const { cleanupStoppedAgents } = await import("../kanban/auto-agent/auto-agent-cleanup.js");
730
+ cleanupStoppedAgents();
731
+
732
+ if (ctx.hasUI) {
733
+ const phase = handler.getActiveFeatureState()?.workflow.currentPhase ?? null;
734
+ ctx.ui.notify?.(
735
+ phase
736
+ ? `Auto-agent stopped. Interactive control resumed (${phase} phase).`
737
+ : slug
738
+ ? "Auto-agent stopped. Interactive control resumed."
739
+ : "No active feature. Auto-agent stopped.",
740
+ "info",
741
+ );
742
+ }
743
+
744
+ persistState(pi, handler);
745
+ updateWidget(handler, NO_FEATURE_STATE);
746
+ },
747
+ });
748
+ }