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,531 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Workflow commands — /fy:next and /fy:resume.
6
+ *
7
+ * Phase transition commands closely related to phase_ready.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import os from "node:os";
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { notifyAutoAgentBlocked, notifyAutoAgentUnblocked } from "../kanban/auto-agent/auto-agent-notify.js";
14
+ import { log } from "../log.js";
15
+ import { setActiveFeatureEnv } from "../phases/env-sync.js";
16
+ import { isPhaseDone, PHASE_TO_SKILL, type Phase } from "../phases/phase-progression.js";
17
+ import { completeFeature, transitionToFinishPhase, transitionToUatPhase } from "../phases/phase-transitions.js";
18
+ import { type RouteResult, routeNext, toRouteConfig } from "../phases/workflow-router.js";
19
+ import { NO_REVIEW_CONTEXT } from "../review/review-loops.js";
20
+ import { getSettings, resolveReviewSkill } from "../settings/settings-ui.js";
21
+ import { NO_AGENT_NAME, NO_FEATURE_STATE_OVERRIDE } from "../shared/workflow-refs.js";
22
+ import type { WorkflowTransitionDeps } from "../shared/workflow-types.js";
23
+ import { withCoordinator } from "../snippets/vendored/subscribe-to-dialog-coordinator.js";
24
+ import {
25
+ archiveArtifactsOlderThan,
26
+ archiveDesignsOlderThan,
27
+ enumerateArchiveSet,
28
+ enumerateDesigns,
29
+ } from "../state/archive-artifacts.js";
30
+ import { ensureFeatyardJunction, resolveArchiveBase, resolveDesignsDirs } from "../state/artifact-junction.js";
31
+ import type { FeatureSession } from "../state/feature-session.js";
32
+ import {
33
+ DEFAULT_DIR,
34
+ deleteStateFile,
35
+ type ExpandSkillCommandFn,
36
+ stateFilePath as featureStateFilePath,
37
+ loadFeatureState,
38
+ markFeatureDone,
39
+ saveFeatureState,
40
+ scanActiveFeatures,
41
+ stateDir,
42
+ syncAndSaveFeatureState,
43
+ } from "../state/feature-state.js";
44
+ import { persistState } from "../state/state-persistence.js";
45
+ import { worthNotesPointerFor } from "../state/worth-notes.js";
46
+ import { NO_FEATURE_STATE, updateWidget } from "../ui/featyard-widget.js";
47
+ import { formatFeatureInfo, openManageDialog } from "../ui/manage-features-dialog.js";
48
+
49
+ export interface WorkflowCommandDeps extends WorkflowTransitionDeps {
50
+ expandSkillCommand: ExpandSkillCommandFn;
51
+ resetSessionTracking: () => void;
52
+ reconstructState: (ctx: ExtensionContext, handler: FeatureSession, stateFilePath: string | false | null) => void;
53
+ getAutoAgentCallback: () => import("../kanban/auto-agent/auto-agent-state-machine.js").AutoAgentCallback | null;
54
+ /** Reset the workflow tracker to fresh state (shared reset used by fy:reset). */
55
+ performWorkflowReset: () => void;
56
+ }
57
+
58
+ /** Resolve the skill for the next phase (review uses resolveReviewSkill; others map via PHASE_TO_SKILL) and dispatch it as a followUp user message. Shared by all phase-advance sites. */
59
+ function dispatchPhaseSkill(pi: ExtensionAPI, nextPhase: string, expandSkillCommandFn: ExpandSkillCommandFn): void {
60
+ const skill =
61
+ nextPhase === "review"
62
+ ? resolveReviewSkill(getSettings())
63
+ : PHASE_TO_SKILL[nextPhase as keyof typeof PHASE_TO_SKILL];
64
+ if (skill)
65
+ pi.sendUserMessage(expandSkillCommandFn(`/skill:${skill}`, NO_FEATURE_STATE_OVERRIDE, NO_AGENT_NAME), {
66
+ deliverAs: "followUp",
67
+ });
68
+ }
69
+
70
+ /** Advance the handler to nextPhase and apply the full post-transition side effects (model override + state persist + widget update + skill dispatch). Shared by the next-phase command handlers when there is no slug-bound stage transition. */
71
+ async function advanceToPhaseWithSideEffects(
72
+ handler: FeatureSession,
73
+ ctx: ExtensionContext,
74
+ pi: ExtensionAPI,
75
+ nextPhase: string,
76
+ applyModelOverrideForPhase: (pi: ExtensionAPI, ctx: ExtensionContext, stage: string) => Promise<void>,
77
+ expandSkillCommandFn: ExpandSkillCommandFn,
78
+ ): Promise<void> {
79
+ handler.setCurrentPhase(nextPhase as Phase);
80
+ await applyModelOverrideForPhase(pi, ctx, nextPhase);
81
+ persistState(pi, handler);
82
+ updateWidget(handler, NO_FEATURE_STATE);
83
+ dispatchPhaseSkill(pi, nextPhase, expandSkillCommandFn);
84
+ }
85
+
86
+ /**
87
+ * Dispatch a {@link RouteResult} for a slug-bound feature: completion → completeFeature;
88
+ * phase → uat/finish-aware stage transition; null → no-op (caller notifies).
89
+ */
90
+ async function executeStageTransition(
91
+ ctx: ExtensionContext,
92
+ route: RouteResult,
93
+ slug: string,
94
+ deps: WorkflowCommandDeps,
95
+ ): Promise<void> {
96
+ const { pi, handler, expandSkillCommand, applyModelOverrideForPhase, handleReviewToUatTransition } = deps;
97
+
98
+ // Terminal: feature completes — clean up worktree, mark done, clear active, fire auto-agent.
99
+ if (route && "completed" in route) {
100
+ const featureState = handler.getActiveFeatureState();
101
+ if (featureState) {
102
+ await completeFeature(slug, featureState, {
103
+ pi,
104
+ ctx,
105
+ handler,
106
+ getAutoAgentCallback: deps.getAutoAgentCallback,
107
+ });
108
+ }
109
+ return;
110
+ }
111
+
112
+ if (!route) return;
113
+
114
+ const nextPhase = route.phase;
115
+ const currentPhase = handler.getWorkflowState()?.currentPhase;
116
+ if (currentPhase === "verify" && nextPhase !== "review") {
117
+ handler.setCurrentPhase("review");
118
+ }
119
+
120
+ if (nextPhase === "uat") {
121
+ const featureState = handler.getActiveFeatureState();
122
+ // Append the worth-notes pointer (existence + path) to the UAT handoff notify so it never stands
123
+ // alone (notifications are exclusive — manual /fy:next review→UAT site).
124
+ const pointer = worthNotesPointerFor(slug);
125
+ const notifyMessage = pointer
126
+ ? `Feature "${slug}" moved to UAT. Use /fy:next to advance.\n${pointer}`
127
+ : `Feature "${slug}" moved to UAT. Use /fy:next to advance.`;
128
+ await transitionToUatPhase(
129
+ ctx,
130
+ slug,
131
+ featureState,
132
+ {
133
+ pi,
134
+ handler,
135
+ applyModelOverrideForPhase,
136
+ getAutoAgentCallback: deps.getAutoAgentCallback,
137
+ },
138
+ {
139
+ kanbanNote: "review complete — UAT handoff",
140
+ notifyMessage,
141
+ },
142
+ );
143
+ return;
144
+ }
145
+
146
+ if (nextPhase === "finish") {
147
+ if (currentPhase === "verify") {
148
+ await handleReviewToUatTransition(ctx, slug, getSettings(), NO_REVIEW_CONTEXT);
149
+ return;
150
+ }
151
+ await transitionToFinishPhase(handler.getActiveFeatureState(), {
152
+ pi,
153
+ ctx,
154
+ handler,
155
+ applyModelOverrideForPhase,
156
+ expandSkillCommand,
157
+ });
158
+ return;
159
+ }
160
+
161
+ handler.setCurrentPhase(nextPhase);
162
+ const featureState = handler.getActiveFeatureState();
163
+ if (featureState) {
164
+ syncAndSaveFeatureState(featureState, handler);
165
+ }
166
+ persistState(pi, handler);
167
+ updateWidget(handler, NO_FEATURE_STATE);
168
+ await applyModelOverrideForPhase(pi, ctx, nextPhase);
169
+ dispatchPhaseSkill(pi, nextPhase, expandSkillCommand);
170
+ }
171
+
172
+ // ── Archive-command shared helpers ───────────────────────────────────────────
173
+ // fy:archive-artifacts and fy:archive-designs share a parse → resolve → gate skeleton.
174
+ // Extracted to keep jscpd clean (the two commands differ only in enumerate/archive fn + messages).
175
+
176
+ type ArchiveCommandCtx = { ui: { notify(message: string, level: "info" | "warning" | "error"): void } };
177
+
178
+ /** Parse the `<days>` arg shared by both archive commands. Returns null (after notifying) on invalid input. */
179
+ function parseArchiveDays(args: string | undefined, ctx: ArchiveCommandCtx, commandLabel: string): number | null {
180
+ const days = Number.parseInt((args ?? "").trim(), 10);
181
+ if (Number.isNaN(days) || days < 0) {
182
+ ctx.ui.notify(`Usage: /${commandLabel} <days> (archive older than N days)`, "error");
183
+ return null;
184
+ }
185
+ return days;
186
+ }
187
+
188
+ /** Resolve the junction + archive base shared by both archive commands. */
189
+ function resolveArchiveContext(): { jr: ReturnType<typeof ensureFeatyardJunction>; archiveBase: string } {
190
+ const jr = ensureFeatyardJunction(
191
+ process.cwd(),
192
+ getSettings().branchPolicy ?? "current-branch",
193
+ process.env.PI_FY_HOME ?? os.homedir(),
194
+ "rename",
195
+ );
196
+ return { jr, archiveBase: resolveArchiveBase(jr) };
197
+ }
198
+
199
+ export function registerWorkflowCommands(deps: WorkflowCommandDeps): void {
200
+ const {
201
+ pi,
202
+ handler,
203
+ expandSkillCommand,
204
+ applyModelOverrideForPhase,
205
+ resetSessionTracking,
206
+ reconstructState,
207
+ performWorkflowReset,
208
+ } = deps;
209
+
210
+ pi.registerCommand("fy:next", {
211
+ description: "Complete current workflow stage and advance to next (skips if preconditions not met)",
212
+ async handler(_args, ctx) {
213
+ const ws = handler.getWorkflowState();
214
+ if (!ws?.currentPhase) {
215
+ if (ctx.hasUI) ctx.ui.notify("No active workflow. Start with /skill:fy-design or /fy:resume.", "warning");
216
+ return;
217
+ }
218
+ const currentPhase = ws.currentPhase;
219
+ const slug = handler.getActiveFeatureSlug();
220
+ const settings = getSettings();
221
+ const featureState = handler.getActiveFeatureState();
222
+ // In the pointer model the current phase is the active pointer; it is "done"
223
+ // only when the feature is completed. Derive from the feature state if present.
224
+ const phaseDone = featureState
225
+ ? isPhaseDone(
226
+ { currentPhase: featureState.workflow.currentPhase, completedAt: featureState.completedAt },
227
+ currentPhase,
228
+ )
229
+ : false;
230
+
231
+ // Resolve the next route. When the current phase is not yet done, let the
232
+ // machine route one step (uat-aware); when already done, route from the
233
+ // pointer directly. Both paths yield a RouteResult that may be terminal.
234
+ const routeConfig = toRouteConfig(settings);
235
+ const route: RouteResult = phaseDone
236
+ ? routeNext(currentPhase, routeConfig)
237
+ : handler.completeCurrentWorkflowPhase(routeConfig);
238
+
239
+ if (!route) {
240
+ if (ctx.hasUI) ctx.ui.notify(`Workflow ended at ${currentPhase}. No more phases.`, "info");
241
+ return;
242
+ }
243
+
244
+ if ("completed" in route) {
245
+ // Terminal — feature completes (e.g. fy:next from uat in after-finish mode,
246
+ // or from finish in after-review/off mode). Replicates the former uat-accept
247
+ // completion path exactly so uat-accept could be dropped.
248
+ if (ctx.hasUI) ctx.ui.notify(`✓ ${currentPhase} completed. Feature done.`, "info");
249
+ if (slug && featureState) {
250
+ await completeFeature(slug, featureState, {
251
+ pi,
252
+ ctx,
253
+ handler,
254
+ getAutoAgentCallback: deps.getAutoAgentCallback,
255
+ });
256
+ } else {
257
+ persistState(pi, handler);
258
+ updateWidget(handler, NO_FEATURE_STATE);
259
+ }
260
+ return;
261
+ }
262
+
263
+ // Non-terminal phase advance.
264
+ if (ctx.hasUI) ctx.ui.notify(`✓ ${currentPhase} completed. Advancing to ${route.phase}...`, "info");
265
+ if (slug) {
266
+ await executeStageTransition(ctx, route, slug, deps);
267
+ } else {
268
+ await advanceToPhaseWithSideEffects(
269
+ handler,
270
+ ctx,
271
+ pi,
272
+ route.phase,
273
+ applyModelOverrideForPhase,
274
+ expandSkillCommand,
275
+ );
276
+ }
277
+ },
278
+ });
279
+
280
+ pi.registerCommand("fy:resume", {
281
+ description: "List active workflows and load the selected one into the current session",
282
+ async handler(_args, ctx) {
283
+ if (!ctx.hasUI) {
284
+ ctx.ui.notify("/fy:resume requires interactive mode.", "error");
285
+ return;
286
+ }
287
+
288
+ while (true) {
289
+ const currentFeatures = scanActiveFeatures(DEFAULT_DIR);
290
+ if (currentFeatures.length === 0) {
291
+ ctx.ui.notify("No active workflows found.", "info");
292
+ return;
293
+ }
294
+
295
+ const featureOptions = currentFeatures.map((f) => `Continue: ${f.featureSlug} — ${formatFeatureInfo(f)}`);
296
+ const allOptions = [...featureOptions, "Skip", "Manage state files"];
297
+ const count = currentFeatures.length;
298
+ const activeSlug = handler.getActiveFeatureSlug();
299
+ if (activeSlug) notifyAutoAgentBlocked(activeSlug);
300
+ const choice = await withCoordinator(() =>
301
+ ctx.ui.select(`Found ${count} active feature${count > 1 ? "s" : ""}:`, allOptions),
302
+ );
303
+ if (activeSlug) notifyAutoAgentUnblocked(activeSlug);
304
+
305
+ if (!choice) return;
306
+
307
+ const featureIdx = featureOptions.indexOf(choice);
308
+ if (featureIdx >= 0) {
309
+ const selectedFeature = currentFeatures[featureIdx];
310
+ if (selectedFeature) {
311
+ const featureState = loadFeatureState(selectedFeature.featureSlug, DEFAULT_DIR);
312
+ const lastSession = featureState?.sessionFiles?.at(-1);
313
+ if (lastSession && fs.existsSync(lastSession)) {
314
+ setActiveFeatureEnv(selectedFeature.featureSlug);
315
+ await ctx.switchSession(lastSession, {
316
+ withSession: async (newCtx) => {
317
+ newCtx.ui.notify(`Resumed session for: ${selectedFeature.featureSlug}`, "info");
318
+ },
319
+ });
320
+ return;
321
+ }
322
+ }
323
+ if (selectedFeature) {
324
+ setActiveFeatureEnv(selectedFeature.featureSlug);
325
+ }
326
+ reconstructState(
327
+ ctx,
328
+ handler,
329
+ selectedFeature ? featureStateFilePath(selectedFeature.featureSlug, DEFAULT_DIR) : null,
330
+ );
331
+ resetSessionTracking();
332
+ persistState(pi, handler);
333
+ updateWidget(handler, NO_FEATURE_STATE);
334
+
335
+ const phase = handler.getWorkflowState()?.currentPhase ?? "unknown";
336
+ ctx.ui.notify(`Loaded workflow: ${selectedFeature?.featureSlug ?? "unknown"} (${phase})`, "info");
337
+ return;
338
+ }
339
+
340
+ if (choice === "Skip") return;
341
+
342
+ if (choice === "Manage state files") {
343
+ const manageResult = await openManageDialog(currentFeatures, ctx);
344
+ if (manageResult) {
345
+ for (const slug of manageResult.slugs) {
346
+ if (manageResult.action === "mark_completed") {
347
+ const state = loadFeatureState(slug, DEFAULT_DIR);
348
+ if (state) saveFeatureState(markFeatureDone(state), DEFAULT_DIR);
349
+ } else {
350
+ deleteStateFile(slug, DEFAULT_DIR);
351
+ }
352
+ }
353
+ }
354
+ continue;
355
+ }
356
+ ctx.ui.notify("Unrecognized selection.", "warning");
357
+ break;
358
+ }
359
+ },
360
+ });
361
+
362
+ pi.registerCommand("fy:archive-artifacts", {
363
+ description: "Archive artifacts older than <days> days (manual sweep). Usage: /fy:archive-artifacts <days>",
364
+ async handler(args, ctx) {
365
+ // 1. Parse <days> (required, non-negative integer).
366
+ const days = parseArchiveDays(args, ctx, "fy:archive-artifacts");
367
+ if (days === null) return;
368
+
369
+ // 2. Resolve the live store + archive base; enumerate what's stale at this threshold.
370
+ const { jr, archiveBase } = resolveArchiveContext();
371
+ const excludeSlug = handler.getActiveFeatureSlug();
372
+ const { stale } = enumerateArchiveSet({
373
+ externalDir: jr.externalDir,
374
+ archiveBase,
375
+ maxAgeDays: days,
376
+ excludeSlug,
377
+ });
378
+
379
+ // 3. Empty short-circuit: nothing stale → notify, NO confirm gate (#5).
380
+ if (stale.length === 0) {
381
+ ctx.ui.notify("Nothing to archive.", "info");
382
+ return;
383
+ }
384
+
385
+ // 4. Confirm gate — headless-safe (#11).
386
+ if (!ctx.hasUI) {
387
+ ctx.ui.notify("/fy:archive-artifacts requires interactive mode to confirm.", "info");
388
+ return;
389
+ }
390
+ // Detect in-flight (active, non-completed) features among the stale set.
391
+ const inFlight = scanActiveFeatures(stateDir())
392
+ .map((s) => s.featureSlug)
393
+ .filter((slug) => stale.some((g) => g.key === slug));
394
+ const inFlightNote =
395
+ inFlight.length > 0
396
+ ? `\n\n⚠️ In-flight features that would be affected: ${inFlight.join(", ")}. Archiving hides their artifacts from .featyard — resume would be artifact-degraded.`
397
+ : "";
398
+ // Count members + groups for the confirm message (what the user is about to archive).
399
+ const memberCount = stale.reduce((n, g) => n + g.members.length, 0);
400
+ const ok = await withCoordinator(() =>
401
+ ctx.ui.confirm(
402
+ `Archive artifacts older than ${days} day${days === 1 ? "" : "s"}?`,
403
+ `This relocates ${memberCount} artifact${memberCount === 1 ? "" : "s"} across ${stale.length} group${stale.length === 1 ? "" : "s"} out of .featyard into the archive. Reversible by moving them back.${inFlightNote}`,
404
+ ),
405
+ );
406
+ if (!ok) {
407
+ ctx.ui.notify("Archive cancelled — no changes made.", "info");
408
+ return;
409
+ }
410
+
411
+ // 5. Archive. archiveArtifactsOlderThan re-enumerates internally (idempotent + skip-if-missing
412
+ // move primitive → worst case a tiny TOCTOU no-op; never corrupts).
413
+ const result = await archiveArtifactsOlderThan({
414
+ externalDir: jr.externalDir,
415
+ archiveBase,
416
+ days,
417
+ excludeSlug,
418
+ });
419
+ // Report at the group level, broken down by type (slug groups vs date-fallback groups). A
420
+ // group counts only when ALL its members moved (all-or-nothing unit) — so a fully-failed
421
+ // group is NOT reported as archived. The feature-state file is a member of its slug's group
422
+ // (co-archived when the group's newest mtime — across ALL members including the state file
423
+ // is stale; skipped if missing), so it needs no separate count. Errors are separate (logged).
424
+ const slugGroups = result.archivedSlugGroups;
425
+ const dateFallbackGroups = result.archivedDateFallbackGroups;
426
+ const errMsg =
427
+ result.errors.length > 0
428
+ ? ` (${result.errors.length} error${result.errors.length === 1 ? "" : "s"} — see log)`
429
+ : "";
430
+ ctx.ui.notify(
431
+ `Archived ${slugGroups} slug${slugGroups === 1 ? "" : "s"} + ${dateFallbackGroups} date-fallback${dateFallbackGroups === 1 ? "" : "s"}.${errMsg}`,
432
+ "info",
433
+ );
434
+ for (const err of result.errors) {
435
+ log.warn(`[fy:archive-artifacts] ${err}`);
436
+ }
437
+ },
438
+ });
439
+
440
+ // --- fy:archive-designs command ---
441
+ // Sweeps BOTH design-doc roots: the out-of-repo `.featyard/designs` (local mode, via the junction) and
442
+ // the in-repo `docs/featyard/designs` (committed mode). Mirrors /fy:archive-artifacts (parse → enumerate
443
+ // → empty short-circuit → confirm gate → archive → report).
444
+ pi.registerCommand("fy:archive-designs", {
445
+ description:
446
+ "Archive design docs older than <days> days (sweeps .featyard/designs and docs/featyard/designs). Usage: /fy:archive-designs <days>",
447
+ async handler(args, ctx) {
448
+ // 1. Parse <days> (required, non-negative integer).
449
+ const days = parseArchiveDays(args, ctx, "fy:archive-designs");
450
+ if (days === null) return;
451
+
452
+ // 2. Resolve BOTH roots + the archive base; enumerate what's stale at this threshold.
453
+ const { jr, archiveBase } = resolveArchiveContext();
454
+ const designsDirs = resolveDesignsDirs(jr.externalDir, process.cwd());
455
+ const excludeSlug = handler.getActiveFeatureSlug();
456
+ const { stale } = enumerateDesigns({
457
+ designsDirs,
458
+ archiveBase,
459
+ maxAgeDays: days,
460
+ excludeSlug,
461
+ });
462
+
463
+ // 3. Empty short-circuit: nothing stale → notify, NO confirm gate.
464
+ if (stale.length === 0) {
465
+ ctx.ui.notify("Nothing to archive.", "info");
466
+ return;
467
+ }
468
+
469
+ // 4. Confirm gate — headless-safe.
470
+ if (!ctx.hasUI) {
471
+ ctx.ui.notify("/fy:archive-designs requires interactive mode to confirm.", "info");
472
+ return;
473
+ }
474
+ // Detect in-flight (active, non-completed) features among the stale set.
475
+ const inFlightSlugs = new Set(
476
+ scanActiveFeatures(stateDir())
477
+ .map((s) => s.featureSlug)
478
+ .filter((slug): slug is string => Boolean(slug)),
479
+ );
480
+ const inFlight = stale.map((m) => m.slug).filter((slug) => inFlightSlugs.has(slug));
481
+ const inFlightNote =
482
+ inFlight.length > 0
483
+ ? `\n\n⚠️ In-flight features that would be affected: ${[...new Set(inFlight)].join(", ")}. Archiving hides their design doc — resume would be degraded.`
484
+ : "";
485
+ const count = stale.length;
486
+ const ok = await withCoordinator(() =>
487
+ ctx.ui.confirm(
488
+ `Archive design docs older than ${days} day${days === 1 ? "" : "s"}?`,
489
+ `This relocates ${count} design doc${count === 1 ? "" : "s"} from .featyard/designs and docs/featyard/designs into the archive. Reversible by moving them back.${inFlightNote}`,
490
+ ),
491
+ );
492
+ if (!ok) {
493
+ ctx.ui.notify("Archive cancelled — no changes made.", "info");
494
+ return;
495
+ }
496
+
497
+ // 5. Archive. archiveDesignsOlderThan re-enumerates internally (idempotent + skip-if-missing
498
+ // move primitive → worst case a tiny TOCTOU no-op; never corrupts).
499
+ const result = await archiveDesignsOlderThan({
500
+ designsDirs,
501
+ archiveBase,
502
+ days,
503
+ excludeSlug,
504
+ });
505
+ const errMsg =
506
+ result.errors.length > 0
507
+ ? ` (${result.errors.length} error${result.errors.length === 1 ? "" : "s"} — see log)`
508
+ : "";
509
+ ctx.ui.notify(
510
+ `Archived ${result.archivedCount} design doc${result.archivedCount === 1 ? "" : "s"}.${errMsg}`,
511
+ "info",
512
+ );
513
+ for (const err of result.errors) {
514
+ log.warn(`[fy:archive-designs] ${err}`);
515
+ }
516
+ },
517
+ });
518
+
519
+ // --- fy:reset command ---
520
+ pi.registerCommand("fy:reset", {
521
+ description: "Reset workflow tracker to fresh state for a new task",
522
+ async handler(_args, ctx) {
523
+ globalThis.__piCtx?.refresh(ctx);
524
+ performWorkflowReset();
525
+ const guard = globalThis.__piCtx;
526
+ if (guard?.hasUI && guard?.ui?.notify) {
527
+ guard.ui.notify("Workflow reset. Ready for a new task.", "info");
528
+ }
529
+ },
530
+ });
531
+ }