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,497 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Auto-agent feature lifecycle functions.
6
+ *
7
+ * Each function receives its dependencies explicitly rather than via closure.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+
12
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { log, NO_ERROR } from "../../log.js";
14
+ import { clearActiveFeatureEnv } from "../../phases/env-sync.js";
15
+ import { type Phase, SKILL_TO_PHASE } from "../../phases/phase-progression.js";
16
+ import { expandSkillCommand } from "../../prompts/skill-block-builder.js";
17
+ import { substitutePlaceholders } from "../../prompts/template-engine.js";
18
+ import { getSettings } from "../../settings/settings-ui.js";
19
+ import { trackSessionFileInState } from "../../state/feature-management.js";
20
+ import type { FeatureState } from "../../state/feature-state.js";
21
+ import type { KanbanDatabase } from "../data/kanban-database.js";
22
+ import type { Feature } from "../data/kanban-types.js";
23
+ import { DEFAULT_NOTIFY_LEVEL } from "../kanban-bridge.js";
24
+ import type { KanbanTools } from "../kanban-operations.js";
25
+ import { cleanupStoppedAgents } from "./auto-agent-cleanup.js";
26
+ import type { GracePeriodManager } from "./auto-agent-grace-period.js";
27
+ import { type AutoAgentStateMachine, computeTargetLane, startAgentHeartbeat } from "./auto-agent-state-machine.js";
28
+
29
+ /** Compute the skill-injection message for a freshly-matched feature: resolves the target phase + builds the expandSkillCommand text (skill + feature title + optional description). Shared by the two activate paths. */
30
+ function buildFeatureActivationMessage(
31
+ result: { feature: Feature; skill: string },
32
+ substituteFn: (text: string) => string,
33
+ ): { targetPhase: Phase; skillMessage: string } {
34
+ const targetPhase: Phase = SKILL_TO_PHASE[result.skill] ?? "design";
35
+ const desc = result.feature.description ? `\n${result.feature.description}` : "";
36
+ const skillMessage = expandSkillCommand(
37
+ `/skill:${result.skill} Work on feature: ${result.feature.title}${desc}`,
38
+ substituteFn,
39
+ );
40
+ return { targetPhase, skillMessage };
41
+ }
42
+
43
+ // --- Constants ---
44
+
45
+ /** Lanes where features enter the workflow (need skill injection) */
46
+ const ENTRY_LANES = new Set(["design", "ready"]);
47
+
48
+ /** Type for the createGracePeriodManager factory stored on globalThis */
49
+ type CreateGracePeriodManagerFn = (sm: AutoAgentStateMachine, tools: KanbanTools) => GracePeriodManager;
50
+
51
+ // --- Context types for extracted functions ---
52
+
53
+ /** Shared workflow-lifecycle callbacks injected into both ActivateFeatureDeps and OnFeatureCompleteDeps. */
54
+ export interface WorkflowLifecycleDeps {
55
+ activateWorkflowForFeature: (slug: string, phase: Phase, ctx: ExtensionContext | null) => Promise<void>;
56
+ resumeWorkflowForFeature: (slug: string, ctx: ExtensionContext | null) => Promise<FeatureState | null>;
57
+ setWorkflowInitiatedNewSession: (message: string | null) => void;
58
+ }
59
+
60
+ /** Dependencies needed by activateFeature */
61
+ export interface ActivateFeatureDeps extends WorkflowLifecycleDeps {
62
+ database: KanbanDatabase;
63
+ sm: AutoAgentStateMachine;
64
+ getTools: () => Promise<KanbanTools>;
65
+ }
66
+
67
+ /** Dependencies needed by onFeatureComplete */
68
+ export interface OnFeatureCompleteDeps extends WorkflowLifecycleDeps {
69
+ getDatabase: () => Promise<KanbanDatabase>;
70
+ getTools: () => Promise<KanbanTools>;
71
+ notify: (msg: string, level: "info" | "warning" | "error") => void;
72
+ pi: ExtensionAPI;
73
+ }
74
+
75
+ // --- Extracted functions ---
76
+
77
+ /**
78
+ * Run an auto-agent-initiated session replacement (newSession/switchSession) with the
79
+ * autoAgentInitiatingReplacement flag held high. session_start fires (via rebind, before
80
+ * withSession) while this flag is set, so the kanban session_start handler treats the
81
+ * replacement as auto-initiated and does not pause the orphaned agent. The flag clears
82
+ * on settle — by then withSession has refreshed __piCtx with a live command context.
83
+ */
84
+ function withAutoAgentReplacement<T>(fn: () => Promise<T>): Promise<T> {
85
+ if (globalThis.__piKanban) globalThis.__piKanban.autoAgentInitiatingReplacement = true;
86
+ return fn().finally(() => {
87
+ if (globalThis.__piKanban) globalThis.__piKanban.autoAgentInitiatingReplacement = false;
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Common helper: start heartbeat, wait timeout, bootstrap feature state,
93
+ * load existing session if available, and inject skill for entry lanes.
94
+ */
95
+ export async function activateFeature(
96
+ result: { feature: Feature; skill: string; kanbanFeatureId: number },
97
+ piCtx: { sendUserMessage: (msg: string, options?: { deliverAs?: "steer" | "followUp" }) => void },
98
+ deps: ActivateFeatureDeps,
99
+ ): Promise<void> {
100
+ const {
101
+ database,
102
+ sm,
103
+ getTools,
104
+ activateWorkflowForFeature,
105
+ resumeWorkflowForFeature,
106
+ setWorkflowInitiatedNewSession,
107
+ } = deps;
108
+
109
+ // Use session guard (refreshed by session_start events and withSession callbacks)
110
+ // instead of captured agentCtx — agentCtx goes stale after newSession/switchSession.
111
+ const guard = globalThis.__piCtx;
112
+ const ctx = {
113
+ switchSession: guard?.switchSession,
114
+ newSession: guard?.newSession,
115
+ sessionManager: guard?.sessionManager,
116
+ };
117
+ // Clean up expired locks from crashed agents before activating
118
+ try {
119
+ const timeoutMs = getSettings().autoLockTimeoutMs ?? 1_800_000;
120
+ const cleaned = database.cleanupExpiredLocks(timeoutMs);
121
+ if (cleaned > 0) {
122
+ log.info(`[kanban] cleaned up ${cleaned} expired lock(s)`);
123
+ }
124
+ } catch (err) {
125
+ log.warn(`[kanban] expired lock cleanup failed: ${err}`);
126
+ }
127
+ startAgentHeartbeat(sm, result.kanbanFeatureId, getTools);
128
+
129
+ const slug = result.feature.slug;
130
+ if (!slug) {
131
+ log.warn("[kanban] activateFeature: feature has no slug, skipping activation");
132
+ return;
133
+ }
134
+ const originalLane = sm.getCurrentFeatureLane() ?? result.feature.lane;
135
+ const isEntryLane = ENTRY_LANES.has(originalLane);
136
+
137
+ // Bootstrap feature state file from kanban if it doesn't exist yet
138
+ let existingState: FeatureState | null = null;
139
+ try {
140
+ const { loadFeatureState, saveFeatureState, createFeatureStateFromKanban, stateDir, stateFilePath } = await import(
141
+ "../../state/feature-state.js"
142
+ );
143
+ existingState = loadFeatureState(slug, stateDir());
144
+ if (!existingState) {
145
+ const state = createFeatureStateFromKanban(slug, { lane: result.feature.lane, branch: null, worktreePath: null });
146
+ state.featureId = result.kanbanFeatureId;
147
+ saveFeatureState(state, stateDir());
148
+ existingState = state;
149
+ } else if (existingState.featureId === null) {
150
+ // Backfill kanbanFeatureId for state files created before kanban tracking
151
+ existingState.featureId = result.kanbanFeatureId;
152
+ saveFeatureState(existingState, stateDir());
153
+ log.info(`[kanban] backfilled kanbanFeatureId=${result.kanbanFeatureId} for existing state ${slug}`);
154
+ }
155
+ // Ensure state_file is populated on the kanban feature. NOTE: the DB `state_file` column
156
+ // stores an ABSOLUTE path captured at creation; after the one-time relocation to
157
+ // .featyard/feature-state/ older rows point at the legacy.pi path and are stale.
158
+ // This is benign — the card renders from DB metadata; resume uses stateDir/scanActiveFeatures
159
+ // as ground truth. Only freshly-created (no state_file) rows get the current path here.
160
+ const feature = database.getFeature(result.kanbanFeatureId);
161
+ if (feature && !feature.state_file) {
162
+ database.updateFeature({
163
+ featureId: result.kanbanFeatureId,
164
+ stateFile: stateFilePath(slug, stateDir()),
165
+ });
166
+ }
167
+ } catch (err) {
168
+ log.warn(`[kanban] failed to bootstrap feature state for ${result.feature.slug}: ${err}`);
169
+ }
170
+
171
+ // --- Session management: ensure feature has a session file tracked ---
172
+ const lastSession = existingState?.sessionFiles?.at(-1);
173
+
174
+ // Placeholder substitution function for skill expansion.
175
+ // Uses the closure's `slug` (from result.feature.slug), NOT process.env.PI_FY_FEATURE
176
+ // (the env var is deleted before this point).
177
+ const substituteFn = (text: string) => substitutePlaceholders(text, { slug });
178
+
179
+ const hasSession = !!(lastSession && fs.existsSync(lastSession) && ctx.switchSession);
180
+ log.info(
181
+ `[kanban] activateFeature: session decision for ${slug} — hasSession=${hasSession}, lastSession=${lastSession ?? "none"}, hasNewSession=${!!ctx.newSession}, hasSwitchSession=${!!ctx.switchSession}`,
182
+ );
183
+ // Track whether skill injection was already handled (newSession path sends via session_start)
184
+ let skillHandled = false;
185
+
186
+ if (hasSession) {
187
+ // Clear the previous feature's env var before switching to prevent session_start
188
+ // from binding the new session to the old feature.
189
+ clearActiveFeatureEnv();
190
+ log.info(`[kanban] activateFeature: loading session ${lastSession} for feature ${slug}`);
191
+ const _desc = result.feature.description ? `\n${result.feature.description}` : "";
192
+ const _skillMessage = expandSkillCommand(
193
+ `/skill:${result.skill} Work on feature: ${result.feature.title}${_desc}`,
194
+ substituteFn,
195
+ );
196
+ const _continuationMessage = `Continuing work on feature: ${result.feature.title}. Pick up where you left off.`;
197
+ const _targetPhase: Phase = SKILL_TO_PHASE[result.skill] ?? "design";
198
+ // All post-switch work MUST happen inside withSession — the old ctx/pi are stale after switchSession.
199
+ const sessionToLoad = lastSession;
200
+ if (sessionToLoad) {
201
+ await withAutoAgentReplacement(async () => {
202
+ await ctx.switchSession?.(sessionToLoad, {
203
+ withSession: async (newCtx) => {
204
+ // Refresh stash with fresh command context from the new session
205
+ globalThis.__piCtx?.refresh(newCtx as unknown as ExtensionCommandContext);
206
+ if (isEntryLane) {
207
+ log.info(`[kanban] activateFeature: activating phase ${_targetPhase} for ${slug} in resumed session`);
208
+ await activateWorkflowForFeature(slug, _targetPhase, newCtx);
209
+ log.info(`[kanban] activateFeature: sending skill command into resumed session for ${slug}`);
210
+ newCtx.sendUserMessage(_skillMessage, { deliverAs: "followUp" });
211
+ } else {
212
+ // Mid-workflow: reconstruct state without advancing phase, then continue.
213
+ await resumeWorkflowForFeature(slug, newCtx);
214
+ log.info(`[kanban] activateFeature: resuming mid-workflow feature ${slug} from session`);
215
+ newCtx.sendUserMessage(_continuationMessage, { deliverAs: "followUp" });
216
+ }
217
+ trackSessionFileInState(newCtx, slug);
218
+ },
219
+ });
220
+ });
221
+ skillHandled = true;
222
+ }
223
+ } else if (ctx.newSession) {
224
+ log.info(`[kanban] activateFeature: creating new session for feature ${slug}`);
225
+ clearActiveFeatureEnv();
226
+ const parentSession = ctx.sessionManager?.getSessionFile?.();
227
+
228
+ const newSession = ctx.newSession;
229
+ if (isEntryLane) {
230
+ const { targetPhase, skillMessage } = buildFeatureActivationMessage(result, substituteFn);
231
+ setWorkflowInitiatedNewSession(skillMessage);
232
+ const res = await withAutoAgentReplacement(() =>
233
+ newSession({
234
+ parentSession: parentSession || undefined,
235
+ withSession: async (newCtx) => {
236
+ globalThis.__piCtx?.refresh(newCtx as unknown as ExtensionCommandContext);
237
+ await activateWorkflowForFeature(slug, targetPhase, newCtx);
238
+ trackSessionFileInState(newCtx, slug);
239
+ skillHandled = true;
240
+ log.info(`[kanban] activateFeature: new session created and tracked for ${slug}`);
241
+ },
242
+ }),
243
+ );
244
+ if (res?.cancelled) {
245
+ log.info(`[kanban] activateFeature: new session cancelled for ${slug}`);
246
+ await activateWorkflowForFeature(slug, targetPhase, ctx as unknown as ExtensionContext);
247
+ trackSessionFileInState(ctx as unknown as ExtensionContext, slug);
248
+ }
249
+ } else {
250
+ const _continuationMsg = `Continuing work on feature: ${result.feature.title}. Pick up where you left off.`;
251
+ setWorkflowInitiatedNewSession(_continuationMsg);
252
+ const res = await withAutoAgentReplacement(() =>
253
+ newSession({
254
+ parentSession: parentSession || undefined,
255
+ withSession: async (newCtx) => {
256
+ globalThis.__piCtx?.refresh(newCtx as unknown as ExtensionCommandContext);
257
+ await resumeWorkflowForFeature(slug, newCtx);
258
+ trackSessionFileInState(newCtx, slug);
259
+ skillHandled = true;
260
+ log.info(`[kanban] activateFeature: new session created for mid-workflow feature ${slug}`);
261
+ },
262
+ }),
263
+ );
264
+ if (res?.cancelled) {
265
+ await resumeWorkflowForFeature(slug, ctx as unknown as ExtensionContext);
266
+ trackSessionFileInState(ctx as unknown as ExtensionContext, slug);
267
+ }
268
+ }
269
+ } else {
270
+ trackSessionFileInState(ctx as unknown as ExtensionContext, slug);
271
+ }
272
+
273
+ // Skill injection (skip if already handled by switchSession/newSession paths above)
274
+ if (!skillHandled) {
275
+ if (isEntryLane) {
276
+ const { targetPhase, skillMessage } = buildFeatureActivationMessage(result, substituteFn);
277
+ await activateWorkflowForFeature(slug, targetPhase, piCtx as unknown as ExtensionContext);
278
+
279
+ log.info("[kanban] activateFeature: sending skill command via sendUserMessage");
280
+ piCtx.sendUserMessage(skillMessage, { deliverAs: "followUp" });
281
+ log.info(`[kanban] activateFeature: skill command dispatched successfully for ${result.feature.slug}`);
282
+ } else {
283
+ await resumeWorkflowForFeature(slug, piCtx as unknown as ExtensionContext);
284
+ log.info(`[kanban] activateFeature: continuing mid-workflow feature ${slug} without session`);
285
+ piCtx.sendUserMessage(`Continuing work on feature: ${result.feature.title}. Pick up where you left off.`, {
286
+ deliverAs: "followUp",
287
+ });
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Handle feature completion notification from workflow-monitor.
294
+ * Called when a feature's workflow completes (all phases done).
295
+ */
296
+ export async function onFeatureComplete(slug: string, deps: OnFeatureCompleteDeps): Promise<void> {
297
+ const { getDatabase, getTools, notify, pi } = deps;
298
+
299
+ log.info(`[kanban] onFeatureComplete called for slug "${slug}"`);
300
+ const current = globalThis.__piKanban?.autoAgent;
301
+ if (current) {
302
+ log.info(
303
+ `[kanban] onFeatureComplete: agent state=${current.getState()}, featureId=${current.getCurrentFeatureId()}, role=${current.getRole()}`,
304
+ );
305
+ }
306
+ // Match working, paused, and waiting agents.
307
+ const { findAnyActiveAgent } = await import("../../commands/kanban-commands.js");
308
+ const match = await findAnyActiveAgent(slug, current ?? null, getDatabase);
309
+ log.info(`[kanban] onFeatureComplete: findAnyActiveAgent result=${match ? "found" : "not found"}`);
310
+ if (!match) {
311
+ // Check actual lock state to give accurate warning
312
+ try {
313
+ const { detectProject } = await import("../data/kanban-detect-project.js");
314
+ const database = await getDatabase();
315
+ const projectId = await detectProject(database, process.cwd());
316
+ const feature = database.findFeatureBySlug(slug, projectId ?? undefined);
317
+ if (feature?.locked_at) {
318
+ log.warn(
319
+ `[kanban] onFeatureComplete: no agent found for slug "${slug}" — feature will stay locked (agent may have moved to another feature)`,
320
+ );
321
+ } else {
322
+ log.info(
323
+ `[kanban] onFeatureComplete: no agent found for slug "${slug}" — lock already released (duplicate or late completion event)`,
324
+ );
325
+ }
326
+ } catch {
327
+ log.warn(`[kanban] onFeatureComplete: no agent found for slug "${slug}" — lock status unknown`);
328
+ }
329
+ return;
330
+ }
331
+ if (match.sm.projectId === undefined) {
332
+ log.warn(`[kanban] onFeatureComplete: agent for slug "${slug}" has undefined projectId`);
333
+ return;
334
+ }
335
+ const { sm, featureId } = match;
336
+ log.info(`[kanban] onFeatureComplete: found agent for slug "${slug}", featureId=${featureId}, role=${sm.getRole()}`);
337
+
338
+ try {
339
+ const tools = await getTools();
340
+ const settings = getSettings();
341
+ const designApprovalEnabled = settings.designApprovalEnabled;
342
+
343
+ const currentFeatureLane = sm.getCurrentFeatureLane();
344
+ if (!currentFeatureLane) {
345
+ log.warn(`[kanban] onFeatureComplete: no currentFeatureLane for slug "${slug}"`);
346
+ try {
347
+ sm.complete();
348
+ } catch {}
349
+ if (sm.getState() === "idle") {
350
+ sm.start();
351
+ sm.noFeatureAvailable();
352
+ }
353
+ const pollingFn = sm.getStartPollingFn();
354
+ if (pollingFn) pollingFn();
355
+ cleanupStoppedAgents();
356
+ return;
357
+ }
358
+
359
+ // Check actual current lane in DB — user may have moved the card while agent worked
360
+ const actualDb = await getDatabase();
361
+ const actualFeature = actualDb.getFeature(featureId);
362
+ const actualLane = actualFeature?.lane;
363
+
364
+ if (actualLane && actualLane !== "design") {
365
+ log.info(
366
+ `[kanban] onFeatureComplete: feature ${featureId} is in lane "${actualLane}" (not design), skipping move`,
367
+ );
368
+ try {
369
+ tools.kanbanRelease({ featureId });
370
+ } catch {}
371
+ sm.complete();
372
+ if (sm.start()) {
373
+ const projectId = sm.projectId;
374
+ if (projectId === undefined) {
375
+ log.warn("[kanban] onFeatureComplete: state machine has no projectId, cannot pick next feature");
376
+ } else {
377
+ const nextResult = sm.pickNextFeature(tools, projectId, sm.sessionId);
378
+ if (nextResult) {
379
+ const doActivate = globalThis.__piKanban?.activateFeature;
380
+ if (!doActivate) {
381
+ log.error("[kanban] activateFeature not available on __piKanban bridge", NO_ERROR);
382
+ return;
383
+ }
384
+ notify(`✅ Auto-${sm.getRole()} finished: "${slug}". Activating next feature...`, DEFAULT_NOTIFY_LEVEL);
385
+ await doActivate(nextResult, pi);
386
+ return;
387
+ }
388
+ }
389
+ }
390
+ const pollingFn2 = sm.getStartPollingFn();
391
+ if (pollingFn2) pollingFn2();
392
+ cleanupStoppedAgents();
393
+ return;
394
+ }
395
+
396
+ // Feature is still in design — normal completion flow
397
+ const targetLane = computeTargetLane(currentFeatureLane, designApprovalEnabled);
398
+ const useGracePeriod = currentFeatureLane === "design" && targetLane === "design-approval";
399
+
400
+ let nextFeature: { feature: Feature; skill: string; kanbanFeatureId: number } | null;
401
+ try {
402
+ if (sm.projectId === undefined) {
403
+ throw new Error("auto-agent state machine has no projectId");
404
+ }
405
+ nextFeature = sm.handleFeatureCompletion(tools, featureId, sm.sessionId, sm.projectId, {
406
+ designApprovalEnabled,
407
+ completeOnly: useGracePeriod,
408
+ });
409
+ } catch (err) {
410
+ log.error(`[kanban] onFeatureComplete: handleFeatureCompletion failed for slug "${slug}"`, err);
411
+ const pollingFn = sm.getStartPollingFn();
412
+ if (pollingFn) pollingFn();
413
+ cleanupStoppedAgents();
414
+ return;
415
+ }
416
+
417
+ // Grace period path (→ design-approval)
418
+ if (useGracePeriod && sm.getState() !== "stopped") {
419
+ if (sm.start()) {
420
+ const createGpm = globalThis.__piKanban?.createGracePeriodManager as CreateGracePeriodManagerFn | undefined;
421
+ if (createGpm) {
422
+ const gpm = createGpm(sm, tools);
423
+ gpm.start();
424
+ sm.enterGracePeriod();
425
+ notify(
426
+ `✅ Auto-${sm.getRole()} finished: "${slug}" → ${targetLane}. Searching for next feature in 30s...`,
427
+ DEFAULT_NOTIFY_LEVEL,
428
+ );
429
+ } else {
430
+ log.warn("[kanban] onFeatureComplete: createGracePeriodManager not available, falling back to polling");
431
+ const pollingFn = sm.getStartPollingFn();
432
+ if (pollingFn) pollingFn();
433
+ }
434
+ }
435
+ return;
436
+ }
437
+
438
+ // Point B: design → ready, no next feature (immediate new session)
439
+ if (currentFeatureLane === "design" && targetLane === "ready" && !nextFeature) {
440
+ const resetFn = globalThis.__piWorkflowMonitor?.performWorkflowReset;
441
+ if (resetFn) resetFn();
442
+
443
+ const guard = globalThis.__piCtx;
444
+ const newSession = guard?.newSession;
445
+ if (newSession) {
446
+ await withAutoAgentReplacement(() =>
447
+ newSession({
448
+ parentSession: guard?.sessionManager?.getSessionFile?.() || undefined,
449
+ withSession: async (newCtx) => {
450
+ globalThis.__piCtx?.refresh(newCtx as unknown as ExtensionCommandContext);
451
+ const resetFn2 = globalThis.__piWorkflowMonitor?.performWorkflowReset;
452
+ if (resetFn2) resetFn2();
453
+ notify(
454
+ `✅ Auto-${sm.getRole()} finished: "${slug}" → ready. New session started, polling for next design feature...`,
455
+ DEFAULT_NOTIFY_LEVEL,
456
+ );
457
+ },
458
+ }),
459
+ );
460
+ } else {
461
+ notify(
462
+ `✅ Auto-${sm.getRole()} finished: "${slug}" → ready. Polling for next design feature...`,
463
+ DEFAULT_NOTIFY_LEVEL,
464
+ );
465
+ }
466
+
467
+ const pollingFn = sm.getStartPollingFn();
468
+ if (pollingFn) pollingFn();
469
+ cleanupStoppedAgents();
470
+ return;
471
+ }
472
+
473
+ // Standard path: next feature found
474
+ if (nextFeature) {
475
+ log.info(`[kanban] onFeatureComplete: picked next feature "${nextFeature.feature.title}"`);
476
+ notify(`✅ Auto-${sm.getRole()} finished: "${slug}". Activating next feature...`, DEFAULT_NOTIFY_LEVEL);
477
+ const doActivate = globalThis.__piKanban?.activateFeature;
478
+ if (!doActivate) {
479
+ log.error("[kanban] activateFeature not available on __piKanban bridge", NO_ERROR);
480
+ return;
481
+ }
482
+ await doActivate(nextFeature, pi);
483
+ return;
484
+ }
485
+
486
+ // No next feature — enter polling
487
+ log.info(`[kanban] onFeatureComplete: no next feature available after "${slug}"`);
488
+ notify(`💤 Auto-${sm.getRole()} finished: "${slug}". Waiting for new features...`, DEFAULT_NOTIFY_LEVEL);
489
+ } catch (err) {
490
+ log.error(`[kanban] onFeatureComplete: handleFeatureCompletion failed for slug "${slug}"`, err);
491
+ }
492
+
493
+ const pollingFn = sm.getStartPollingFn();
494
+ if (pollingFn) pollingFn();
495
+ log.info("[kanban] auto-agent: no more features available, polling...");
496
+ cleanupStoppedAgents();
497
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Auto-agent notification helpers.
6
+ *
7
+ * Notify the auto-agent state machine when the agent is blocked
8
+ * waiting for user input or when user provides input.
9
+ */
10
+
11
+ import type { AutoAgentCallback } from "./auto-agent-state-machine.js";
12
+
13
+ /** Module-level ref to getAutoAgentCallback, set during initialization */
14
+ let _getAutoAgentCallback: (() => AutoAgentCallback | null) | null = null;
15
+
16
+ /** Set the getAutoAgentCallback implementation (called by orchestrator) */
17
+ export function setAutoAgentCallbackGetter(getter: () => AutoAgentCallback | null): void {
18
+ _getAutoAgentCallback = getter;
19
+ }
20
+
21
+ /** Notify auto-agent that the agent is blocked waiting for user input */
22
+ export function notifyAutoAgentBlocked(slug: string): void {
23
+ try {
24
+ const autoAgentCb = _getAutoAgentCallback?.();
25
+ if (autoAgentCb?.onBlock) {
26
+ autoAgentCb.onBlock(slug);
27
+ }
28
+ } catch {
29
+ // Auto-agent notification must not disrupt workflow
30
+ }
31
+ }
32
+
33
+ /** Notify auto-agent that user provided input and agent can continue */
34
+ export function notifyAutoAgentUnblocked(slug: string): void {
35
+ try {
36
+ const autoAgentCb = _getAutoAgentCallback?.();
37
+ if (autoAgentCb?.onUnblock) {
38
+ autoAgentCb.onUnblock(slug);
39
+ }
40
+ } catch {
41
+ // Auto-agent notification must not disrupt workflow
42
+ }
43
+ }
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Pause an orphaned auto-agent after an external session replacement.
6
+ *
7
+ * newSession/switchSession are command-context-only: they cannot be refreshed from
8
+ * a session_start event ctx (ExtensionContext), so a manual /new, /resume, /fork,
9
+ * or /reload leaves them pinned to the now-dead runner. Rather than crash on the
10
+ * next feature pick, pause the agent and ask the user to re-run its start command,
11
+ * which mints a fresh command context and resumes the loop.
12
+ */
13
+
14
+ import { log } from "../../log.js";
15
+ import type { AutoAgentRole } from "./auto-agent-state-machine.js";
16
+
17
+ /** Map an auto-agent role to its resume command + display name. */
18
+ function roleResumeInfo(role: AutoAgentRole): { command: string; display: string } {
19
+ const command = role === "worker" ? "fy:auto-worker" : role === "designer" ? "fy:auto-designer" : "fy:auto-agent";
20
+ return { command, display: `Auto-${role}` };
21
+ }
22
+
23
+ /**
24
+ * Pause an orphaned auto-agent if one is in an active state.
25
+ *
26
+ * @param requestWidgetUpdate Request a widget re-render after pausing.
27
+ * @returns true if an agent was paused, false if none was active.
28
+ */
29
+ export function pauseOrphanedAutoAgent(requestWidgetUpdate: () => void): boolean {
30
+ const sm = globalThis.__piKanban?.autoAgent;
31
+ if (!sm) return false;
32
+ const state = sm.getState();
33
+ // Only pause states that rely on newSession/switchSession for the next activation.
34
+ // paused/stopped/error/idle are left untouched.
35
+ if (state !== "working" && state !== "polling" && state !== "waiting" && state !== "grace-period") return false;
36
+ const gpm = globalThis.__piKanban?.gracePeriod;
37
+ if (gpm) gpm.stop();
38
+ const { command, display } = roleResumeInfo(sm.getRole());
39
+ sm.pause();
40
+ globalThis.__piCtx?.notify(`${display} paused after manual session change. Re-run /${command} to resume.`, "warning");
41
+ requestWidgetUpdate();
42
+ log.info(`[kanban] session_start: orphaned ${display} paused after external session replacement (was ${state})`);
43
+ return true;
44
+ }