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,371 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Compaction module — session_compact handler + empty loop tracking + skill resolution.
6
+ *
7
+ * Implements the ICompaction interface for cross-module communication.
8
+ * Tracks agent-finished state, reviewer empty loops, and resolves the expected
9
+ * skill for the current workflow phase.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
17
+ import { log } from "../log.js";
18
+ import { PHASE_TO_SKILL } from "../phases/phase-progression.js";
19
+ import { NO_FEATURE_STATE_OVERRIDE } from "../prompts/skill-expansion.js";
20
+ import { EmptyLoopTracker } from "../review/review-empty-loop-tracking.js";
21
+ import { getSettings } from "../settings/settings-ui.js";
22
+ import type { ICompaction } from "../shared/workflow-types.js";
23
+ import type { FeatureSession } from "../state/feature-session.js";
24
+ import type { ExpandSkillCommandFn } from "../state/feature-state.js";
25
+ import { buildCompactFraming, buildCompactSkillBlock } from "./compact-message.js";
26
+ import { safeSetEditorText } from "./safe-editor-write.js";
27
+
28
+ // phase→skill map to phase-progression.ts — review/uat phases handled dynamically in getExpectedSkill()
29
+ export interface CompactionDeps {
30
+ handler: FeatureSession;
31
+ expandSkillCommand: ExpandSkillCommandFn;
32
+ resolveReviewSkill: (settings: { maxFeatureReviewRounds: number }) => string | null;
33
+ agentJustFinishedRef: { value: boolean };
34
+ /** Id of the item just completed by the todo_complete that triggered this compaction (consume-on-read),
35
+ * or null when this compaction was not triggered by item completion. Injected to decouple from pi-todo. */
36
+ getCompletedItemId: () => string | null;
37
+ /** The current in-progress todo item formatted for followUp (`In progress: ▶ id: name\ndetails`),
38
+ * or null. Injected to decouple from pi-todo. */
39
+ getInProgressItem: () => string | null;
40
+ }
41
+
42
+ /**
43
+ * Create the compaction module.
44
+ *
45
+ * Owns:
46
+ * - EmptyLoopTracker instance
47
+ * - agentJustFinished flag
48
+ * - getExpectedSkill() resolution
49
+ * - session_compact handler registration
50
+ */
51
+
52
+ /**
53
+ * How long the mid-turn compaction follow-up is deferred before inject (ms).
54
+ *
55
+ * The follow-up is injected on a timer (not inline in the session_compact handler) so that a user
56
+ * steer typed DURING compaction is delivered first: compaction_end fires flushCompactionQueue
57
+ * (which sends the buffered steer as a prompt) right after session_compact; by this delay the
58
+ * steer's turn is streaming, so the deferred sendUserMessage enqueues as a followUp (drained after
59
+ * the steer) instead of racing the steer prompt and hanging it. In the no-steer case the agent is
60
+ * idle at fire time, so sendUserMessage sends it as a prompt and starts the follow-up turn itself.
61
+ * sendUserMessage self-adapts — no detection of whether the user steered is needed.
62
+ *
63
+ * Must exceed the steer prompt's prep window (compaction_end → isStreaming=true); that window is
64
+ * sub-ms for sync handlers, so 500ms is ample margin. Exported so tests advance fake timers by the
65
+ * real value rather than a magic number.
66
+ */
67
+ export const DEFERRED_COMPACT_FOLLOWUP_MS = 500;
68
+
69
+ export function createCompaction(pi: ExtensionAPI, deps: CompactionDeps): ICompaction {
70
+ const {
71
+ handler,
72
+ expandSkillCommand,
73
+ resolveReviewSkill,
74
+ agentJustFinishedRef,
75
+ getCompletedItemId,
76
+ getInProgressItem,
77
+ } = deps;
78
+ const emptyLoopTracker = new EmptyLoopTracker();
79
+ const DEFERRED_FOLLOWUP_MS = DEFERRED_COMPACT_FOLLOWUP_MS;
80
+ let pendingFollowUpTimer: ReturnType<typeof setTimeout> | undefined;
81
+
82
+ function clearPendingFollowUp(): void {
83
+ if (pendingFollowUpTimer !== undefined) {
84
+ clearTimeout(pendingFollowUpTimer);
85
+ pendingFollowUpTimer = undefined;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Defer a compaction follow-up inject by DEFERRED_FOLLOWUP_MS so a user steer typed DURING
91
+ * compaction is delivered first (compaction_end → flushCompactionQueue sends it as a prompt;
92
+ * by fire time the steer's turn is streaming, so this enqueues as a followUp instead of racing
93
+ * it). In the no-steer case the agent is idle at fire time, so sendUserMessage sends it as a
94
+ * prompt and starts the turn itself — self-adapts, no detection needed. Shared by the main
95
+ * inject branch and the subagent path (subagents are non-interactive today, but a future
96
+ * steer-capable subagent would hit the same race, so both defer). Replaces any prior pending
97
+ * follow-up (a newer compaction supersedes).
98
+ */
99
+ function scheduleDeferredFollowUp(message: string): void {
100
+ clearPendingFollowUp();
101
+ pendingFollowUpTimer = setTimeout(() => {
102
+ pendingFollowUpTimer = undefined;
103
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
104
+ }, DEFERRED_FOLLOWUP_MS);
105
+ }
106
+
107
+ // --- ICompaction interface ---
108
+
109
+ function setAgentFinished(value: boolean): void {
110
+ agentJustFinishedRef.value = value;
111
+ }
112
+
113
+ function incrementEmptyLoop(slug: string, reviewer: string): void {
114
+ emptyLoopTracker.incrementEmptyLoop(slug, reviewer);
115
+ }
116
+
117
+ function resetEmptyLoop(slug: string, reviewer: string): void {
118
+ emptyLoopTracker.resetEmptyLoop(slug, reviewer);
119
+ }
120
+
121
+ function isReviewerSkipped(slug: string, reviewer: string, threshold: number): boolean {
122
+ return emptyLoopTracker.isReviewerSkipped(slug, reviewer, threshold);
123
+ }
124
+
125
+ function resetTracking(): void {
126
+ clearPendingFollowUp();
127
+ agentJustFinishedRef.value = false;
128
+ emptyLoopTracker.resetAllEmptyLoops();
129
+ }
130
+
131
+ // --- Skill resolution ---
132
+
133
+ /**
134
+ * Determine which skill the LLM should be following based on current workflow phase.
135
+ * Returns null if no phase is active or no skill is expected.
136
+ */
137
+ function getExpectedSkill(): string | null {
138
+ const state = handler.getWorkflowState();
139
+ if (!state?.currentPhase) return null;
140
+ const { currentPhase } = state;
141
+ // In the pointer model the current phase is always in-progress; bail out
142
+ // only when the feature is already completed.
143
+ const slug = process.env.PI_FY_FEATURE;
144
+ const featureState = handler.getActiveFeatureState();
145
+ if (featureState?.completedAt) return null;
146
+
147
+ // Review iteration sub-states
148
+ if (slug) {
149
+ if (
150
+ currentPhase === "design" &&
151
+ (featureState?.design.reviewLoopCount ?? 0) > 0 &&
152
+ featureState?.design.reviewActive
153
+ ) {
154
+ return "fy-design-review";
155
+ }
156
+ if (currentPhase === "plan" && (featureState?.plan.reviewLoopCount ?? 0) > 0 && featureState?.plan.reviewActive) {
157
+ return "fy-plan-review";
158
+ }
159
+ }
160
+
161
+ if (currentPhase === "implement") {
162
+ return "fy-implement";
163
+ }
164
+ if (currentPhase === "review") {
165
+ return resolveReviewSkill(getSettings());
166
+ }
167
+ return PHASE_TO_SKILL[currentPhase] ?? null;
168
+ }
169
+
170
+ // --- Subagent skill injection helper ---
171
+
172
+ /**
173
+ * Read skills from a local agent definition file.
174
+ * Agents are defined in the featyard `agents/` directory — no external dependency needed.
175
+ */
176
+ function getAgentSkills(agentName: string): string[] {
177
+ // Resolve agents/ relative to the featyard package root
178
+ // (this file is at src/compaction/compact-handler.ts)
179
+ const thisFile = fileURLToPath(import.meta.url);
180
+ const packageRoot = path.resolve(path.dirname(thisFile), "../..");
181
+ const agentFile = path.join(packageRoot, "agents", `${agentName}.md`);
182
+
183
+ try {
184
+ if (!fs.existsSync(agentFile)) return [];
185
+ const content = fs.readFileSync(agentFile, "utf-8");
186
+ const { frontmatter } = parseFrontmatter<Record<string, string>>(content);
187
+ const skillStr = frontmatter.skill || frontmatter.skills;
188
+ return (
189
+ skillStr
190
+ ?.split(",")
191
+ .map((s: string) => s.trim())
192
+ .filter(Boolean) ?? []
193
+ );
194
+ } catch (err) {
195
+ log.warn(`Failed to read agent file ${agentFile}: ${err instanceof Error ? err.message : err}`);
196
+ return [];
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Handle compaction for subagent sessions — inject the subagent's declared skill(s).
202
+ * Returns true if this was a subagent session (caller should return early).
203
+ */
204
+ async function handleSubagentCompact(): Promise<boolean> {
205
+ const subagentAgentName = process.env.PI_SUBAGENT_CHILD_AGENT;
206
+ if (!subagentAgentName) return false;
207
+
208
+ const compactedMsg = `Context was compacted. You are subagent "${subagentAgentName}". Continue your task.`;
209
+ let message: string;
210
+ try {
211
+ const skills = getAgentSkills(subagentAgentName);
212
+ if (skills.length) {
213
+ const skillNames = skills.join(", ");
214
+ log.info(`Injecting subagent skills after compaction: ${skillNames} (agent: ${subagentAgentName})`);
215
+ const skillParts = skills
216
+ .map((s) => expandSkillCommand(`/skill:${s}`, NO_FEATURE_STATE_OVERRIDE, subagentAgentName))
217
+ .join("\n\n");
218
+ message = `${skillParts}\n${compactedMsg}`;
219
+ } else {
220
+ log.info(`Subagent ${subagentAgentName} has no declared skills — injecting role reminder`);
221
+ message = compactedMsg;
222
+ }
223
+ } catch (err) {
224
+ log.warn(`Subagent skill injection failed: ${err instanceof Error ? err.message : err}`);
225
+ message = compactedMsg;
226
+ }
227
+ // Deferred (not inline): subagents are non-interactive today, but a future steer-capable
228
+ // subagent would hit the same user-steer race, so this path defers like the main inject branch.
229
+ scheduleDeferredFollowUp(message);
230
+ return true;
231
+ }
232
+
233
+ // --- Pending dialog restoration helper ---
234
+
235
+ // --- session_compact handler ---
236
+
237
+ /**
238
+ * Deliver the stored featyard follow-up — shared by session_compact (compact
239
+ * succeeded) and the failed-compact recovery (onError). A failed compact still
240
+ * interrupted the agent's turn (ctx.compact aborts it up front), so the agent must
241
+ * be resumed with the SAME full follow-up as a success: a phase-transition compact
242
+ * must inject the new phase's skill; an inter-task compact confirms the new current
243
+ * task; a todo-triggered compact reveals the next item. session_compact is the
244
+ * success path; on failure the follow-up is still in __piCompactFollowUp (session_
245
+ * compact never fired), so this reads + deletes it, resolves the skill, assembles the
246
+ * message, routes editor-vs-inject, and runs onAfterFollowUp (which clears the guard).
247
+ */
248
+ async function deliverStoredFollowUp(reason: "manual" | "threshold" | "overflow" | undefined): Promise<void> {
249
+ // --- Stored pending-follow-up from a featyard caller (inter-task compact, review loop). ---
250
+ // Caller provides { skillName?, message, onAfterFollowUp? } — message is the specific note only
251
+ // (no /skill: prefix, no generic framing); this handler owns the skill + framing line.
252
+ const storedFollowUp = globalThis.__piCompactFollowUp;
253
+ delete globalThis.__piCompactFollowUp;
254
+
255
+ // --- Todo parts: completedId only when item-completion triggered this compaction; the in-progress item always. ---
256
+ const completedItemId = getCompletedItemId();
257
+ const inProgressItem = getInProgressItem();
258
+
259
+ const state = handler.getWorkflowState();
260
+ const phase = state?.currentPhase ?? undefined;
261
+
262
+ // Resolve skill: caller's explicit skillName wins, else the phase's expected skill.
263
+ const skillName = storedFollowUp?.skillName ?? getExpectedSkill();
264
+
265
+ // Nothing to inject (no skill, no caller note, no completed item, no in-progress item) — run the callback and stop.
266
+ if (!skillName && !storedFollowUp?.message && !completedItemId && !inProgressItem) {
267
+ storedFollowUp?.onAfterFollowUp?.();
268
+ return;
269
+ }
270
+
271
+ // --- Is this a user-initiated manual compact? ---
272
+ // `reason: "manual"` covers /compact, the UI button, AND extension ctx.compact() — all route through
273
+ // session.compact(). To single out the *user*-initiated case, require NO extension/todo trigger:
274
+ // extension-triggered compaction sets __piCompactFollowUp first; todo-triggered (hosted pi-todo)
275
+ // sets the completed item id. With neither, a "manual" reason means the user compacted themselves.
276
+ // Such compactions must NEVER auto-inject (sendUserMessage) — the user is in control and may steer next;
277
+ // auto-injecting would start a blocking agent turn that hangs the user's steer. (regression fix)
278
+ const isUserInitiatedManual = reason === "manual" && !storedFollowUp && !completedItemId;
279
+
280
+ // --- Assemble ONE message: [skill block] + [framing] + [caller note] + [✅] + [In progress] ---
281
+ const parts: string[] = [];
282
+ const skillBlock = buildCompactSkillBlock(skillName, expandSkillCommand);
283
+ if (skillBlock) parts.push(skillBlock);
284
+ // Framing is always emitted (single source of truth here) — even with no skill (e.g. UAT).
285
+ parts.push(buildCompactFraming(skillName, phase));
286
+ if (storedFollowUp?.message) parts.push(storedFollowUp.message);
287
+ if (completedItemId) parts.push(`✅ ${completedItemId}`);
288
+ if (inProgressItem) parts.push(inProgressItem);
289
+ const message = parts.join("\n\n");
290
+
291
+ // --- Route to editor (no auto-inject) vs. inject followUp (auto-resume). ---
292
+ // Editor when the user is in control: a user-initiated manual compact, OR the agent just finished
293
+ // its turn (human's turn). Inject otherwise (auto/extension compaction while the agent is mid-turn).
294
+ // No fallback skill: when there's no mapped skill (workflow inactive / no caller skillName),
295
+ // nothing skill-related is injected, and no editor hint is shown (the notify IS the hint —
296
+ // if there's no skill, there's nothing valuable to surface).
297
+ const notifyMsg = skillName
298
+ ? `Editor has content — compaction follow-up not injected. Run /skill:${skillName} to continue.`
299
+ : null;
300
+ if (isUserInitiatedManual || agentJustFinishedRef.value) {
301
+ const route = isUserInitiatedManual ? "user-initiated manual" : "turn-end";
302
+ log.info(`Routing compaction followUp to editor (${route}) — skill: ${skillName ?? "none"}, phase: ${phase}`);
303
+ safeSetEditorText(message, notifyMsg);
304
+ } else {
305
+ log.info(
306
+ `Injecting compaction followUp (mid-turn, deferred ${DEFERRED_FOLLOWUP_MS}ms) — skill: ${skillName ?? "none"}, phase: ${phase}`,
307
+ );
308
+ scheduleDeferredFollowUp(message);
309
+ }
310
+
311
+ storedFollowUp?.onAfterFollowUp?.();
312
+ }
313
+
314
+ /**
315
+ * Failed-compact recovery: ctx.compact({onError}) routes EVERY failure (nothing-to-
316
+ * compact, no-model, mid-summarization API error) here. The turn was already aborted by
317
+ * compact(), so resume the agent — with the same full follow-up as a successful compact
318
+ * (deliverStoredFollowUp). Mirrors session_compact: a subagent session injects the
319
+ * subagent's declared skills. reason is unknown on the failure path; treat as manual, but
320
+ * isUserInitiatedManual is false (there IS a stored follow-up). Exposed on ICompaction and
321
+ * injected into triggerContextCompact callers as the onError handler (clean DI — no
322
+ * service-locator ref).
323
+ */
324
+ function recoverCompactFailure(): void {
325
+ log.info("compaction recovery — delivering the stored follow-up after a failed compact");
326
+ clearPendingFollowUp();
327
+ void handleSubagentCompact()
328
+ .then((handled) => {
329
+ if (handled) return;
330
+ return deliverStoredFollowUp("manual");
331
+ })
332
+ .catch((err) => {
333
+ // Never let the recovery path itself fail silently — that would orphan the agent mid-turn.
334
+ log.error("compaction recovery failed — agent may not be resumed", err);
335
+ });
336
+ }
337
+
338
+ async function onSessionCompact(event: { reason: "manual" | "threshold" | "overflow" }): Promise<void> {
339
+ // A newer compaction supersedes any still-pending deferred follow-up from a prior compaction.
340
+ clearPendingFollowUp();
341
+ log.info(
342
+ `session_compact fired — reason=${event.reason ?? "unknown"}, agentJustFinished=${agentJustFinishedRef.value}`,
343
+ );
344
+
345
+ if (await handleSubagentCompact()) return;
346
+
347
+ await deliverStoredFollowUp(event.reason);
348
+ }
349
+
350
+ // Clear any pending deferred follow-up when the session tears down (quit/new/resume/fork/reload
351
+ // route through resetTracking above; this covers the raw shutdown path so a deferred inject never
352
+ // fires into a dead session).
353
+ function onSessionShutdown(): void {
354
+ clearPendingFollowUp();
355
+ }
356
+
357
+ return {
358
+ setAgentFinished,
359
+ incrementEmptyLoop,
360
+ resetEmptyLoop,
361
+ isReviewerSkipped,
362
+ resetTracking,
363
+ getEmptyLoopsForSlug: (slug: string) => emptyLoopTracker.getEmptyLoopsForSlug(slug),
364
+ resetAllEmptyLoops: () => emptyLoopTracker.resetAllEmptyLoops(),
365
+ getReviewerEmptyLoops: () => emptyLoopTracker.getReviewerEmptyLoops(),
366
+ getExpectedSkill,
367
+ recoverCompactFailure,
368
+ onSessionCompact,
369
+ onSessionShutdown,
370
+ };
371
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Compaction message building.
6
+ *
7
+ * The compact-handler (compaction.ts) assembles ONE followUp message from parts:
8
+ * [skill block] + [framing line] + [caller note] + [✅ completedId] + [In progress item]
9
+ *
10
+ * This module provides the skill block and the framing line. The skill reference is passed
11
+ * through expandSkillCommand, which resolves all `{{PI_FY_*}}` placeholders (including
12
+ * review-iteration context for fy-design-review/fy-plan-review skills) — no post-hoc substitution needed.
13
+ *
14
+ * Framing is owned here (single source of truth) so callers never restate it, which is what
15
+ * caused the old duplicate-skill / double-framing in the stored-message path.
16
+ */
17
+
18
+ import { NO_AGENT_NAME, NO_FEATURE_STATE_OVERRIDE } from "../shared/workflow-refs.js";
19
+ import type { ExpandSkillCommandFn } from "../state/feature-state.js";
20
+
21
+ /** Whether a skill is a review-iteration skill (gets a review-specific framing). */
22
+ function isReviewSkill(skill: string | null | undefined): boolean {
23
+ return skill === "fy-design-review" || skill === "fy-plan-review";
24
+ }
25
+
26
+ /**
27
+ * The single compaction framing line, always emitted once. Declarative ("Reminder of planned
28
+ * work") with an appended precedence directive so the agent prioritizes the user's most recent
29
+ * instruction over this auto-injected follow-up (prevents the verbose skill block from burying a
30
+ * concurrent user steer).
31
+ * - review skill → "Context was compacted. Reminder of planned work: continue the review from where you left off. Honor the user's most recent instruction first; this is a reminder, not a new directive."
32
+ * - phase skill → "Context was compacted. Reminder of planned work: you are in ${phase} phase; continue from where you left off. Honor the user's most recent instruction first; this is a reminder, not a new directive."
33
+ * - no skill → "Context was compacted. Reminder of planned work: continue from where you left off. Honor the user's most recent instruction first; this is a reminder, not a new directive."
34
+ */
35
+ export function buildCompactFraming(skill: string | null | undefined, phase: string | undefined): string {
36
+ if (isReviewSkill(skill)) {
37
+ return "Context was compacted. Reminder of planned work: continue the review from where you left off. Honor the user's most recent instruction first; this is a reminder, not a new directive.";
38
+ }
39
+ if (phase) {
40
+ return `Context was compacted. Reminder of planned work: you are in ${phase} phase; continue from where you left off. Honor the user's most recent instruction first; this is a reminder, not a new directive.`;
41
+ }
42
+ return "Context was compacted. Reminder of planned work: continue from where you left off. Honor the user's most recent instruction first; this is a reminder, not a new directive.";
43
+ }
44
+
45
+ /** The expanded `<skill>` block for the given skill, or "" when there is no skill. */
46
+ export function buildCompactSkillBlock(
47
+ skill: string | null | undefined,
48
+ expandSkillCommand: ExpandSkillCommandFn,
49
+ ): string {
50
+ if (!skill) return "";
51
+ return expandSkillCommand(`/skill:${skill}`, NO_FEATURE_STATE_OVERRIDE, NO_AGENT_NAME);
52
+ }
@@ -0,0 +1,121 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Shared context-compaction trigger.
6
+ *
7
+ * One helper for every extension-driven compaction boundary in featyard:
8
+ * - implement plan-task boundaries (`task_ready_advance`, `interTaskCompact` setting)
9
+ * - design/plan review iteration boundaries (`reviewIterationCompact` setting)
10
+ * - code-review loop boundaries (`reviewIterationCompact` setting)
11
+ *
12
+ * It checks the caller-supplied setting value (none / compact / compact>NK), the
13
+ * optional token threshold, sets the stored compact follow-up (skill + framing +
14
+ * caller note), and fires `ctx.compact()`. The compact-handler (compaction.ts)
15
+ * owns the framing line ("Context was compacted...") and the skill expansion;
16
+ * the caller owns the specific note only.
17
+ *
18
+ * Keeping this in a leaf module avoids the circular import that would otherwise
19
+ * arise: phase-ready.ts already imports from review-loops.ts, so review-loops.ts
20
+ * cannot import back from phase-ready.ts to reach the old review-iteration reset.
21
+ */
22
+
23
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import { log, NO_ERROR } from "../log.js";
25
+ import { parseContextCompactValue } from "../settings/settings-schema.js";
26
+
27
+ // Re-entrancy guard: a single compact can be in flight at a time.
28
+ let compactGuardActive = false;
29
+
30
+ /** Reset the re-entrancy guard. For tests and session lifecycle. */
31
+ export function _resetCompactGuard(): void {
32
+ compactGuardActive = false;
33
+ }
34
+
35
+ /** Sentinel for callers that need no post-compact callback (lint:bare-literals forbids bare null). */
36
+ export const NO_COMPACT_CALLBACK: (() => void) | null = null;
37
+
38
+ /** Caller-supplied payload describing the compaction to trigger. */
39
+ export interface CompactPayload {
40
+ /** The setting value (none / compact / compact>NK) for this boundary. */
41
+ settingValue: string;
42
+ /**
43
+ * Skill to re-inject after compaction. Omitted → the compact-handler derives the
44
+ * phase's expected skill (implement→fy-implement, review→fy-review,
45
+ * design/plan review sub-states→fy-design-review/fy-plan-review).
46
+ */
47
+ skillName?: string;
48
+ /** Caller note appended after the skill + framing. No `/skill:` prefix, no framing. */
49
+ message: string;
50
+ /** Label for log lines identifying which boundary triggered the compact. */
51
+ logLabel: string;
52
+ }
53
+
54
+ /**
55
+ * Trigger a context compact at a work-unit boundary.
56
+ *
57
+ * @returns `true` if a compact was actually initiated (caller may stop / skip its
58
+ * own follow-up since the compact handler will re-inject the skill).
59
+ */
60
+ export async function triggerContextCompact(
61
+ ctx: ExtensionContext,
62
+ payload: CompactPayload,
63
+ onAfterFollowUp: (() => void) | null,
64
+ recover: () => void,
65
+ ): Promise<boolean> {
66
+ const { settingValue, skillName, message, logLabel } = payload;
67
+
68
+ if (compactGuardActive) {
69
+ log.info(`${logLabel}: already in progress, skipping`);
70
+ return false;
71
+ }
72
+
73
+ const { mode, threshold } = parseContextCompactValue(settingValue);
74
+ if (mode !== "compact") {
75
+ log.info(`${logLabel}: mode=${mode}, nothing to do`);
76
+ return false;
77
+ }
78
+
79
+ if (threshold !== null) {
80
+ const usage = ctx.getContextUsage ? await ctx.getContextUsage() : null;
81
+ if (!usage?.tokens || usage.tokens <= threshold) {
82
+ log.info(`${logLabel}: skipping compact — tokens (${usage?.tokens ?? "null"}) <= threshold (${threshold})`);
83
+ return false;
84
+ }
85
+ log.info(`${logLabel}: tokens (${usage.tokens}) > threshold (${threshold})`);
86
+ }
87
+
88
+ compactGuardActive = true;
89
+ globalThis.__piCompactFollowUp = {
90
+ skillName,
91
+ message,
92
+ onAfterFollowUp: () => {
93
+ compactGuardActive = false;
94
+ onAfterFollowUp?.();
95
+ },
96
+ };
97
+ // Fire the compact. ctx.compact() is fire-and-forget (void); it aborts the current turn,
98
+ // then pi emits session_compact on success (delivers the stored follow-up + clears the
99
+ // guard) or routes the failure to onError. On ANY failure (nothing-to-compact, no-model,
100
+ // mid-summarization API error) the turn was already aborted, so the agent must be resumed
101
+ // with the full compact-handler follow-up — the injected `recover` does that (it owns pi +
102
+ // the skill assembly, supplied by the caller's deps). This module owns only the policy +
103
+ // guard/follow-up state; recovery is a pure injected dependency.
104
+ try {
105
+ ctx.compact({
106
+ onError: (err: Error) => {
107
+ log.error(`${logLabel}: compact failed: ${err.message}`, NO_ERROR);
108
+ recover();
109
+ },
110
+ });
111
+ } catch (err) {
112
+ // Defensive: ctx.compact is fire-and-forget (void) and routes failures to onError, but
113
+ // a synchronous throw (before onError can fire) would leak compactGuardActive and orphan
114
+ // the follow-up — clean up so later compacts are not silently skipped.
115
+ log.error(`${logLabel}: compact threw synchronously: ${(err as Error).message}`, NO_ERROR);
116
+ delete globalThis.__piCompactFollowUp;
117
+ compactGuardActive = false;
118
+ return false;
119
+ }
120
+ return true;
121
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Safely set the editor text if it's currently empty.
6
+ * If editor has content, shows an info notification instead.
7
+ */
8
+ export function safeSetEditorText(text: string, notifyMsg: string | null): void {
9
+ const guard = globalThis.__piCtx;
10
+ const ui = guard?.ui;
11
+ if (!ui?.setEditorText) return;
12
+ const existing = ui.getEditorText?.();
13
+ if (!existing?.trim()) {
14
+ ui.setEditorText(text);
15
+ } else if (notifyMsg) {
16
+ ui.notify(notifyMsg, "info");
17
+ }
18
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * agent_end event router — delegate to the agent-lifecycle domain method.
6
+ *
7
+ * Clears the finish guardrail, resets the phase_ready guard, flags the agent
8
+ * finished (for compaction), and notifies the auto-agent on non-retryable
9
+ * execution-phase errors. The logic lives in the AgentLifecycle domain object.
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import type { IAgentLifecycle } from "../../shared/workflow-types.js";
14
+
15
+ export function registerAgentEnd(pi: ExtensionAPI, lifecycle: IAgentLifecycle): void {
16
+ pi.on("agent_end", async (event, ctx) => {
17
+ await lifecycle.onAgentEnd(event, ctx);
18
+ });
19
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * agent_settled event router — delegate to the agent-lifecycle domain method.
6
+ *
7
+ * Fires once per user prompt when pi has no pending retry/compaction/followUp
8
+ * continuation (pi will not continue running automatically). Used to deliver the
9
+ * staged phase-transition followUp (deferred) so it does not race a concurrently
10
+ * typed user message.
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import type { IAgentLifecycle } from "../../shared/workflow-types.js";
15
+
16
+ export function registerAgentSettled(pi: ExtensionAPI, lifecycle: IAgentLifecycle): void {
17
+ pi.on("agent_settled", async () => {
18
+ await lifecycle.onAgentSettled();
19
+ });
20
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * agent_start event router — delegate to the agent-lifecycle domain method.
6
+ *
7
+ * Re-arms the finish-phase guardrail and signals a fresh agent turn to the
8
+ * compaction module. The logic lives in the AgentLifecycle domain object.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { IAgentLifecycle } from "../../shared/workflow-types.js";
13
+
14
+ export function registerAgentStart(pi: ExtensionAPI, lifecycle: IAgentLifecycle): void {
15
+ pi.on("agent_start", async () => {
16
+ await lifecycle.onAgentStart();
17
+ });
18
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * turn_end event router — delegate to the kanban turn-handler domain method.
6
+ *
7
+ * Captures the active model (for kanban title/topic generation) and resumes the
8
+ * grace-period timer. The logic lives in the KanbanTurnHandlers domain object
9
+ * (kanban/). This coordinates the two turn_end concerns (model capture + grace
10
+ * resume) that were previously separate pi.on handlers.
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import type { KanbanTurnHandlers } from "../../kanban/kanban-turn-handlers.js";
15
+
16
+ export function registerTurnEnd(pi: ExtensionAPI, handlers: KanbanTurnHandlers): void {
17
+ pi.on("turn_end", (_event, ctx) => {
18
+ handlers.onTurnEnd(ctx);
19
+ });
20
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * turn_start event router — delegate to the kanban turn-handler domain method.
6
+ *
7
+ * Pauses the grace-period timer and unblocks a "waiting" auto-agent (a new agent
8
+ * turn means the session is active again). The logic lives in the
9
+ * KanbanTurnHandlers domain object (kanban/).
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import type { KanbanTurnHandlers } from "../../kanban/kanban-turn-handlers.js";
14
+
15
+ export function registerTurnStart(pi: ExtensionAPI, handlers: KanbanTurnHandlers): void {
16
+ pi.on("turn_start", () => {
17
+ handlers.onTurnStart();
18
+ });
19
+ }