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,663 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Guardrails module — per-tool gating logic for tool_call + tool_result events.
6
+ *
7
+ * The pi.on event registration lives in events/tool/ (tool-call.ts, tool-result.ts);
8
+ * this module owns the per-tool logic (onBashCall, onWriteEditCall, onPhaseReadyCall,
9
+ * onTaskReadyAdvanceCall, onReadResult, onWriteEditResult, onBashResult) that those
10
+ * routers dispatch to.
11
+ *
12
+ * Handles TDD enforcement, publish gating, phase write restrictions, pre-commit
13
+ * discipline, `.featyard/` force-add blocking, and review loop tracking.
14
+ *
15
+ * All factory-coupled dependencies are injected via GuardrailsDeps.
16
+ */
17
+
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import type { ExtensionAPI, ExtensionContext, ToolCallEvent, ToolResultEvent } from "@earendil-works/pi-coding-agent";
21
+ import { getStagedFiles, PROCESS_CWD } from "../git/git-queries.js";
22
+ import { log } from "../log.js";
23
+ import { isPhaseActive, WORKFLOW_PHASES } from "../phases/phase-progression.js";
24
+ import { getSettings } from "../settings/settings-ui.js";
25
+ import type {
26
+ BashResultEvent,
27
+ ICompaction,
28
+ IGuardrails,
29
+ ToolCallDecision,
30
+ ToolResultAdvisory,
31
+ } from "../shared/workflow-types.js";
32
+ import { type ActivationDeps, activateFromDocWrite } from "../state/feature-activation.js";
33
+ import type { FeatureSession, Violation } from "../state/feature-session.js";
34
+ import {
35
+ createFeatureState as createFeatureStateFile,
36
+ createFeatureStateFromPlan,
37
+ DESIGN_DOC_DIRS,
38
+ type ExpandSkillCommandFn,
39
+ type FeatureState,
40
+ FY_RESEARCH_DIR,
41
+ FY_REVIEWS_DIR,
42
+ FY_TASK_PLANS_DIR,
43
+ featureSlugFromDesignDoc,
44
+ featureSlugFromPlanDoc,
45
+ recordReviewHistory,
46
+ } from "../state/feature-state.js";
47
+ import { isSubagentSession } from "../state/state-persistence.js";
48
+ import { isPublishAction, PUBLISH_BEFORE_FINISH_REASON, promptPublishGate } from "./completion-gating.js";
49
+ import { changeSetCoversSource, isSourceFile } from "./file-classifier.js";
50
+ import { checkFeatyardForceAdd, DEFAULT_FS } from "./force-add-guard.js";
51
+ import { GuardrailTracker } from "./guardrail-tracker.js";
52
+ import { applyDisciplineGate, formatViolationWarning } from "./guardrail-violations.js";
53
+ import { decompose } from "./shell-decompose.js";
54
+ import { detectTestOutcome, isTestRun } from "./test-output.js";
55
+
56
+ /**
57
+ * Factory-coupled dependencies that must be injected from the orchestrator.
58
+ */
59
+ /** Dependencies for completion gating and skill command expansion. */
60
+ export interface CompletionGateDeps {
61
+ handler: FeatureSession;
62
+ pi: ExtensionAPI;
63
+ expandSkillCommand: ExpandSkillCommandFn;
64
+ }
65
+
66
+ /** Dependencies for verify phase entry and execution mode. */
67
+ export interface VerifyPhaseDeps {
68
+ handler: FeatureSession;
69
+ applyExecutionMode: (ctx: ExtensionContext) => Promise<void>;
70
+ expandSkillCommand: ExpandSkillCommandFn;
71
+ applyModelOverrideForPhase: (pi: ExtensionAPI, ctx: ExtensionContext, stage: string) => Promise<void>;
72
+ }
73
+
74
+ /** Dependencies for review loop handling (guardrails-specific callback wrapper). */
75
+ export interface GuardrailReviewLoopDeps {
76
+ handler: FeatureSession;
77
+ handleReviewLoopEnd: (
78
+ ctx: ExtensionContext,
79
+ opts: {
80
+ slug: string;
81
+ featureState: FeatureState;
82
+ issuesFound: number;
83
+ cannotFixIssues: number;
84
+ logPrefix: string;
85
+ },
86
+ ) => Promise<void>;
87
+ }
88
+
89
+ /** Dependencies for git operations within guardrails. */
90
+ export interface GitGuardrailDeps {
91
+ createGitExec: (
92
+ ctx: ExtensionContext,
93
+ ) => (command: string, options?: { cwd?: string }) => Promise<{ exitCode: number; stdout: string }>;
94
+ }
95
+
96
+ /** Dependencies for feature state management within guardrails. */
97
+ export interface FeatureStateGuardrailDeps {
98
+ recoverArtifactsFromDisk: (handler: FeatureSession, data: { featureSlug?: string | null }) => void;
99
+ handleSubFeatureWrite: (
100
+ ctx: ExtensionContext,
101
+ slug: string,
102
+ filePath: string,
103
+ artifactType: "design" | "plan",
104
+ activeSlugForLog: string | null,
105
+ ) => Promise<void>;
106
+ }
107
+
108
+ export interface GuardrailsDeps
109
+ extends CompletionGateDeps,
110
+ VerifyPhaseDeps,
111
+ GuardrailReviewLoopDeps,
112
+ GitGuardrailDeps,
113
+ FeatureStateGuardrailDeps {
114
+ compaction: ICompaction;
115
+ }
116
+
117
+ /**
118
+ * Parse review reports to track which dispatched reviewers found zero issues.
119
+ *
120
+ * Maps reviewer agent names to report categories using the convention:
121
+ * - `fy-quality-reviewer` → category `quality` (strip `fy-` prefix + `-reviewer` suffix)
122
+ * - Legacy `reviewer-quality` → category `quality` (strip `reviewer-` prefix; backward-compat)
123
+ * - Bare names like `quality` → used as-is
124
+ *
125
+ * A reviewer is considered "empty" if the report has no `**Category:** <category>`
126
+ * line matching its mapped category. Empty-loop tracking is used by the
127
+ * compaction module's skip-threshold logic to skip reviewers that have found
128
+ * nothing for consecutive loops.
129
+ *
130
+ * This name-to-category mapping is a contract between:
131
+ * 1. The fy-review skill's SKILL.md (defines reviewer agent names)
132
+ * 2. The review report format (uses `**Category:** <category>` per issue)
133
+ * 3. This function (maps agent name → category to detect empty loops)
134
+ */
135
+ /** Build the common prefix for empty-loop tracking log messages. */
136
+ function emptyLoopPrefix(slug: string, loop: number, zeroIssues: boolean | undefined): string {
137
+ return `[workflow] Review report for slug=${slug} loop=${loop}${zeroIssues ? " (zero-issues)" : ""}`;
138
+ }
139
+
140
+ /** Load the feature state for the handler's currently-active feature slug. Returns null when there is no active slug or no state file. Returns both slug and state so callers that need the slug downstream (e.g. trackReviewerEmptyLoops) don't re-fetch it. */
141
+ function loadActiveFeatureState(handler: FeatureSession): { slug: string; featureState: FeatureState } | null {
142
+ const slug = handler.getActiveFeatureSlug();
143
+ if (!slug) return null;
144
+ const featureState = handler.getActiveFeatureState();
145
+ if (!featureState) return null;
146
+ return { slug, featureState };
147
+ }
148
+
149
+ /** Find the review report file for the given slug and loop number. Returns null if not found. */
150
+ async function findReviewReport(reviewsDir: string, slug: string, currentLoop: number): Promise<string | null> {
151
+ try {
152
+ await fs.promises.access(reviewsDir);
153
+ } catch {
154
+ return null;
155
+ }
156
+ const files = await fs.promises.readdir(reviewsDir);
157
+ const reportFiles = files.filter((f) => f.startsWith(`${slug}-review-${currentLoop}`));
158
+ return reportFiles.length > 0 ? path.join(reviewsDir, reportFiles[0]) : null;
159
+ }
160
+
161
+ /** Extract the dispatched reviewers list from report content. Returns null if not parseable. */
162
+ function parseDispatchedReviewers(reportContent: string): string[] | null {
163
+ const dispatchedMatch = reportContent.match(/\*\*Reviewers dispatched:\*\* (.+)/);
164
+ if (!dispatchedMatch) return null;
165
+ const reviewers = dispatchedMatch[1]
166
+ .split(", ")
167
+ .map((s: string) => s.trim())
168
+ .filter((s) => s.length > 0);
169
+ return reviewers.length > 0 ? reviewers : null;
170
+ }
171
+
172
+ /** Classify a single reviewer's empty-loop status and update compaction tracker. */
173
+ function classifyReviewerEmptyLoop(
174
+ slug: string,
175
+ reviewer: string,
176
+ reportContent: string,
177
+ zeroIssues: boolean | undefined,
178
+ compaction: ICompaction,
179
+ ): void {
180
+ if (zeroIssues) {
181
+ compaction.incrementEmptyLoop(slug, reviewer);
182
+ return;
183
+ }
184
+ // Derive the review category from the reviewer name. After the fy-* rename, reviewer
185
+ // names are `fy-<category>-reviewer` (e.g. fy-quality-reviewer); the report's `**Category:**`
186
+ // line uses the bare category (e.g. `quality`). Strip BOTH the `fy-` prefix and the `-reviewer`
187
+ // suffix — stripping only `-reviewer` would yield `fy-quality` ≠ `quality` and silently break
188
+ // empty-loop tracking.
189
+ const category = reviewer.startsWith("reviewer-")
190
+ ? reviewer.slice("reviewer-".length)
191
+ : reviewer.endsWith("-reviewer")
192
+ ? reviewer.slice(0, -"-reviewer".length).replace(/^fy-/, "")
193
+ : reviewer.replace(/^fy-/, "");
194
+ const foundIssues = reportContent.includes(`**Category:** ${category}`);
195
+ if (foundIssues) {
196
+ compaction.resetEmptyLoop(slug, reviewer);
197
+ } else {
198
+ compaction.incrementEmptyLoop(slug, reviewer);
199
+ }
200
+ }
201
+
202
+ async function trackReviewerEmptyLoops(opts: {
203
+ slug: string;
204
+ currentLoop: number;
205
+ compaction: ICompaction;
206
+ zeroIssues?: boolean;
207
+ }): Promise<void> {
208
+ const { slug, currentLoop, compaction, zeroIssues } = opts;
209
+ const reviewsDir = path.join(process.cwd(), "docs", "reviews", slug);
210
+ try {
211
+ const reportPath = await findReviewReport(reviewsDir, slug, currentLoop);
212
+ if (!reportPath) {
213
+ log.warn(`${emptyLoopPrefix(slug, currentLoop, zeroIssues)} not found, skipping empty-loop tracking`);
214
+ return;
215
+ }
216
+ const reportContent = await fs.promises.readFile(reportPath, "utf-8");
217
+ const dispatchedReviewers = parseDispatchedReviewers(reportContent);
218
+ if (!dispatchedReviewers) {
219
+ log.warn(
220
+ `${emptyLoopPrefix(slug, currentLoop, zeroIssues)} has no 'Reviewers dispatched' line, skipping empty-loop tracking`,
221
+ );
222
+ return;
223
+ }
224
+ for (const reviewer of dispatchedReviewers) {
225
+ classifyReviewerEmptyLoop(slug, reviewer, reportContent, zeroIssues, compaction);
226
+ }
227
+ } catch (err) {
228
+ log.error(
229
+ `${emptyLoopPrefix(slug, currentLoop, zeroIssues)}: failed to read for empty-loop tracking`,
230
+ err instanceof Error ? err.message : err,
231
+ );
232
+ }
233
+ }
234
+
235
+ const COMMIT_RE = /\bgit\s+commit\b/;
236
+
237
+ function checkPreCommitGate(
238
+ command: string,
239
+ handler: FeatureSession,
240
+ ): { uncovered: string[]; notVerified: boolean } | null {
241
+ const settings = getSettings();
242
+ if (settings.preCommitDiscipline === "off") return null;
243
+
244
+ if (!COMMIT_RE.test(command)) return null;
245
+
246
+ const stagedFiles = getStagedFiles(PROCESS_CWD);
247
+ const sourceFiles = stagedFiles.filter((f) => isSourceFile(f));
248
+ if (sourceFiles.length === 0) return null;
249
+
250
+ // Coverage check (concern #2): each staged source must have a corresponding
251
+ // test in the SAME staged set (by stem, layout-independent). This is stricter
252
+ // than the old on-disk existence check — a commit must carry the test change —
253
+ // and consistent with the write-time rule (#1). Pre-existing untouched tests
254
+ // do not satisfy it.
255
+ const uncovered = sourceFiles.filter((f) => !changeSetCoversSource(f, stagedFiles));
256
+
257
+ const verification = handler.getVerificationState();
258
+ const notVerified = verification === "not-run";
259
+
260
+ if (uncovered.length > 0 || notVerified) {
261
+ return { uncovered, notVerified };
262
+ }
263
+ return null;
264
+ }
265
+
266
+ function buildPreCommitWarning(result: { uncovered: string[]; notVerified: boolean }): string {
267
+ const parts: string[] = ["⚠️ PRE-COMMIT GATE:"];
268
+ if (result.uncovered.length > 0) {
269
+ parts.push(`Staged source files lack test files: ${result.uncovered.join(", ")}.`);
270
+ }
271
+ if (result.notVerified) {
272
+ parts.push("Run tests before committing.");
273
+ } else if (result.uncovered.length > 0) {
274
+ parts.push("Write tests before committing.");
275
+ }
276
+ return parts.join(" ");
277
+ }
278
+
279
+ function buildPreCommitBlockReason(result: { uncovered: string[]; notVerified: boolean }): string {
280
+ const parts: string[] = ["Pre-commit gate:"];
281
+ if (result.uncovered.length > 0) {
282
+ parts.push(`staged source files lack test coverage: ${result.uncovered.join(", ")}`);
283
+ }
284
+ if (result.notVerified) {
285
+ parts.push("complete verification checks before committing");
286
+ }
287
+ return parts.join(" ");
288
+ }
289
+
290
+ /**
291
+ * Create the guardrails module with injected dependencies.
292
+ */
293
+ export function createGuardrails(deps: GuardrailsDeps): IGuardrails {
294
+ const {
295
+ pi,
296
+ handler,
297
+ compaction,
298
+ applyModelOverrideForPhase,
299
+ handleSubFeatureWrite,
300
+ createGitExec,
301
+ handleReviewLoopEnd,
302
+ } = deps;
303
+
304
+ // Activation callbacks for the doc-write activator (git exec + sub-feature writer).
305
+ const activationDeps: ActivationDeps = { createGitExec, handleSubFeatureWrite };
306
+
307
+ // --- Mutable state ---
308
+ const pendingViolations = new Map<string, Violation>();
309
+ const pendingPreCommitWarnings = new Map<string, string>();
310
+ const pendingProcessWarnings = new Map<string, string>();
311
+
312
+ const guardrailTracker = new GuardrailTracker();
313
+ const maybeEscalate = guardrailTracker.maybeEscalate.bind(guardrailTracker);
314
+
315
+ let verifyTestsPassed = false;
316
+
317
+ /** Shared activation flow for a design-doc or task-plan-doc write.
318
+ *
319
+ * - ACTIVE feature === slug: recordDoc (caller) already recorded the doc
320
+ * into the handler's authoritative record — no file I/O (avoids divergence).
321
+ * - No active feature: bootstrap — record the doc on an existing state file, or
322
+ * create a new one, register it in the kanban (target lane), and activate it.
323
+ * - A DIFFERENT feature is active: treat as a sub-feature write. */
324
+ // --- tool_call handler ---
325
+ /** bash tool_call: fy-force-add block, publish gate, pre-commit discipline. */
326
+ async function onBashCall(event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallDecision> {
327
+ const command = ((event.input as Record<string, unknown>).command as string | undefined) ?? "";
328
+
329
+ // Hard-block any `git add -f` that would pull in the `.featyard/` artifact junction
330
+ // (external storage, auto-gitignored). Always active — runs before any workflow
331
+ // state check, so it blocks even when no featyard workflow is active. `.featyard/`
332
+ // must never enter the repo history. See fy-guardrail.ts for path/sweep detection.
333
+ const featyardBlock = checkFeatyardForceAdd(command, PROCESS_CWD, DEFAULT_FS);
334
+ if (featyardBlock) {
335
+ return { block: featyardBlock.reason };
336
+ }
337
+
338
+ const state = handler.getWorkflowState();
339
+ const phaseIdx = state?.currentPhase ? WORKFLOW_PHASES.indexOf(state.currentPhase) : -1;
340
+ const implementIdx = WORKFLOW_PHASES.indexOf("implement");
341
+ const subcommands = decompose(command);
342
+ // Publish gate: `git push` / `gh pr create` are blocked until the finish phase.
343
+ // Before finish → hard-block (the human can still push directly via terminal — the
344
+ // gate only sees agent tool calls). In finish → confirm via showSelectWithNote
345
+ // (Allow/Block, default Block; forwarded to the root session for subagents).
346
+ // Commits are NOT gated here — pre-commit discipline (below) owns commits.
347
+ if (state?.currentPhase && isPublishAction(subcommands)) {
348
+ if (state.currentPhase === "finish") {
349
+ const gateResult = await promptPublishGate({ ctx });
350
+ if (gateResult === "blocked") {
351
+ return { block: "Publish cancelled." };
352
+ }
353
+ } else {
354
+ return { block: PUBLISH_BEFORE_FINISH_REASON };
355
+ }
356
+ }
357
+
358
+ if (phaseIdx >= implementIdx) {
359
+ const settings = getSettings();
360
+ const preCommitResult = checkPreCommitGate(command, handler);
361
+ if (preCommitResult) {
362
+ const warningReason = buildPreCommitWarning(preCommitResult);
363
+ const blockResult = applyDisciplineGate({
364
+ discipline: settings.preCommitDiscipline,
365
+ blockReason: buildPreCommitBlockReason(preCommitResult),
366
+ warning: warningReason,
367
+ pendingMap: pendingPreCommitWarnings,
368
+ toolCallId: event.toolCallId,
369
+ });
370
+ if (blockResult) return { block: blockResult.reason };
371
+ }
372
+ }
373
+ return {};
374
+ }
375
+
376
+ /** write/edit tool_call: TDD check, verify flag reset, phase-write-restriction, doc activation. */
377
+ async function onWriteEditCall(event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallDecision> {
378
+ const toolCallId = event.toolCallId;
379
+ const settings = getSettings();
380
+ const input = event.input as Record<string, unknown>;
381
+ const filePath = input.path as string | undefined;
382
+ if (!filePath) return {};
383
+
384
+ // A write/edit to a source file clears the verification credit and triggers
385
+ // the TDD write-order check.
386
+ let changed = false;
387
+ if (isSourceFile(filePath)) {
388
+ handler.recordSourceWrite();
389
+ changed = true; // verification state mutated — router must persist
390
+ }
391
+ const result = handler.checkSourceWriteOrder(filePath);
392
+ if (result) {
393
+ // The only violation kind is the TDD write-order check (source-before-test),
394
+ // which is stateless — nothing to "commit" after allowing the write.
395
+ if (result.type === "source-before-test" && settings.testingDiscipline !== "off") {
396
+ if (settings.testingDiscipline === "tdd-strict") {
397
+ const escalation = await maybeEscalate("tdd-write-order", ctx, `${event.toolName}: ${filePath}`);
398
+ if (escalation === "block") {
399
+ return { block: "TDD write-order violation: write tests before source code (tdd-strict mode)." };
400
+ }
401
+ }
402
+ pendingViolations.set(toolCallId, result);
403
+ }
404
+ }
405
+
406
+ const state = handler.getWorkflowState();
407
+ const phase = state?.currentPhase;
408
+ if (
409
+ phase === "verify" &&
410
+ state &&
411
+ isPhaseActive({ currentPhase: state.currentPhase, completedAt: null }, "verify") &&
412
+ isSourceFile(filePath)
413
+ ) {
414
+ verifyTestsPassed = false;
415
+ }
416
+ const isThinkingPhase = phase === "design" || phase === "plan";
417
+ let normalizedForCheck = filePath;
418
+ if (normalizedForCheck.startsWith("./")) normalizedForCheck = normalizedForCheck.slice(2);
419
+ const resolved = path.resolve(process.cwd(), normalizedForCheck);
420
+ // Design docs may live in either recognized dir (committed docs/featyard/designs OR local .featyard/designs);
421
+ // detection/guarding recognize BOTH so docs from either mode count as design-doc writes.
422
+ const designRoots = DESIGN_DOC_DIRS.map((d) => path.join(process.cwd(), d) + path.sep);
423
+ const taskPlansRoot = path.join(process.cwd(), FY_TASK_PLANS_DIR) + path.sep;
424
+ const reviewsRoot = path.join(process.cwd(), FY_REVIEWS_DIR) + path.sep;
425
+ const researchRoot = path.join(process.cwd(), FY_RESEARCH_DIR) + path.sep;
426
+ const isDesignWrite = designRoots.some((root) => resolved.startsWith(root));
427
+ const isTaskPlanWrite = resolved.startsWith(taskPlansRoot);
428
+ const isReviewsWrite = resolved.startsWith(reviewsRoot);
429
+ const isResearchWrite = resolved.startsWith(researchRoot);
430
+ const isAllowedThinkingPhaseWrite = isDesignWrite || isTaskPlanWrite || isReviewsWrite || isResearchWrite;
431
+
432
+ if (isThinkingPhase && !isAllowedThinkingPhaseWrite) {
433
+ const escalation = await maybeEscalate("phase-write-restriction", ctx, `${event.toolName}: ${filePath}`);
434
+ if (escalation === "block") {
435
+ return {
436
+ block: `Phase write restriction: during ${phase} phase, only write to docs/featyard/designs/ (or .featyard/designs/), ${FY_TASK_PLANS_DIR}/, ${FY_REVIEWS_DIR}/, or ${FY_RESEARCH_DIR}/.`,
437
+ };
438
+ }
439
+
440
+ pendingProcessWarnings.set(
441
+ toolCallId,
442
+ `⚠️ PROCESS VIOLATION: Wrote ${filePath} during ${phase} phase.\n` +
443
+ `During designing/planning you may only write to docs/featyard/designs/ (or .featyard/designs/), ${FY_TASK_PLANS_DIR}/, ${FY_REVIEWS_DIR}/, or ${FY_RESEARCH_DIR}/. Stop and return or advance workflow phases intentionally.`,
444
+ );
445
+ }
446
+
447
+ const relativeForTracker =
448
+ isDesignWrite || isTaskPlanWrite ? path.relative(process.cwd(), resolved).replace(/\\/g, "/") : filePath;
449
+ changed = handler.recordDoc(relativeForTracker) || changed;
450
+
451
+ // Design / plan doc write detection — shared activation flow (both doc
452
+ // types register a kanban feature: design doc -> "design" lane,
453
+ // task-plan -> "in-progress" lane).
454
+ const designSlug = featureSlugFromDesignDoc(filePath);
455
+ if (designSlug) {
456
+ changed =
457
+ (await activateFromDocWrite(ctx, handler, activationDeps, {
458
+ slug: designSlug,
459
+ filePath,
460
+ artifactType: "design",
461
+ kanbanLane: "design",
462
+ docSlot: {
463
+ getDoc: (s) => s.design.doc,
464
+ setDoc: (s, v) => {
465
+ s.design.doc = v;
466
+ },
467
+ },
468
+ create: createFeatureStateFile,
469
+ postCreate: null,
470
+ })) || changed;
471
+ }
472
+
473
+ const planSlug = featureSlugFromPlanDoc(filePath);
474
+ if (planSlug) {
475
+ changed =
476
+ (await activateFromDocWrite(ctx, handler, activationDeps, {
477
+ slug: planSlug,
478
+ filePath,
479
+ artifactType: "plan",
480
+ kanbanLane: "in-progress",
481
+ docSlot: {
482
+ getDoc: (s) => s.plan.doc,
483
+ setDoc: (s, v) => {
484
+ s.plan.doc = v;
485
+ },
486
+ },
487
+ create: createFeatureStateFromPlan,
488
+ postCreate: async () => {
489
+ handler.setCurrentPhase("plan");
490
+ await applyModelOverrideForPhase(pi, ctx, "plan");
491
+ },
492
+ })) || changed;
493
+ }
494
+
495
+ return { changed };
496
+ }
497
+
498
+ /** phase_ready tool_call: subagent + implement-phase gating. */
499
+ function onPhaseReadyCall(_event: ToolCallEvent): ToolCallDecision {
500
+ if (isSubagentSession()) {
501
+ return { block: "phase_ready is not available in subagent sessions." };
502
+ }
503
+ const phaseReadyWs = handler.getWorkflowState();
504
+ if (phaseReadyWs?.currentPhase === "implement") {
505
+ return {
506
+ block:
507
+ "During implementation, use task_ready_advance to move between tasks, and to advance to verify on the last task (call it with nextTask omitted). phase_ready is not used in implement.",
508
+ };
509
+ }
510
+ return {};
511
+ }
512
+
513
+ /** task_ready_advance tool_call: subagent gating. */
514
+ function onTaskReadyAdvanceCall(_event: ToolCallEvent): ToolCallDecision {
515
+ if (isSubagentSession()) {
516
+ return { block: "task_ready_advance is not available in subagent sessions." };
517
+ }
518
+ return {};
519
+ }
520
+
521
+ // --- tool_result sub-handlers ---
522
+
523
+ /** Handle read tool_result: file path tracking. */
524
+ /** read tool_result: log + record investigation. */
525
+ function onReadResult(event: ToolResultEvent): ToolResultAdvisory {
526
+ const filePath = ((event.input as Record<string, unknown>).path as string) ?? "";
527
+ log.info(`[workflow] tool_result read: path=${filePath}`);
528
+ handler.handleReadOrInvestigation("read", filePath);
529
+ return { warnings: [] };
530
+ }
531
+
532
+ /** write/edit tool_result: pending TDD violations + process warnings. */
533
+ function onWriteEditResult(toolCallId: string): ToolResultAdvisory {
534
+ const warnings: string[] = [];
535
+ const violation = pendingViolations.get(toolCallId);
536
+ if (violation) {
537
+ warnings.push(formatViolationWarning(violation));
538
+ }
539
+ pendingViolations.delete(toolCallId);
540
+
541
+ const processWarning = pendingProcessWarnings.get(toolCallId);
542
+ if (processWarning) {
543
+ warnings.push(processWarning);
544
+ }
545
+ pendingProcessWarnings.delete(toolCallId);
546
+ return { warnings };
547
+ }
548
+
549
+ /** bash tool_result: record test outcome + verify flag, pending pre-commit warning. */
550
+ function onBashResult(event: BashResultEvent, toolCallId: string): ToolResultAdvisory {
551
+ const warnings: string[] = [];
552
+ const command = (event.input.command as string) ?? "";
553
+ const output = event.content
554
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
555
+ .map((c) => c.text)
556
+ .join("\n");
557
+ const exitCode = (event.details as { exitCode?: number })?.exitCode;
558
+ // Parse the bash result here. A test command updates the verification gate;
559
+ // non-test commands leave it untouched.
560
+ const isTestCommand = isTestRun(command);
561
+ const passed = isTestCommand ? detectTestOutcome(output, exitCode) : null;
562
+ let changed = false;
563
+ if (passed !== null) {
564
+ handler.recordTestOutcome(passed);
565
+ changed = true;
566
+ }
567
+
568
+ const state = handler.getWorkflowState();
569
+ if (
570
+ isTestCommand &&
571
+ state?.currentPhase === "verify" &&
572
+ state &&
573
+ isPhaseActive({ currentPhase: state.currentPhase, completedAt: null }, "verify")
574
+ ) {
575
+ if (passed === true) {
576
+ verifyTestsPassed = true;
577
+ log.info("[workflow] verify: tests passed, flag set.");
578
+ } else if (passed === false) {
579
+ verifyTestsPassed = false;
580
+ }
581
+ }
582
+
583
+ const preCommitWarning = pendingPreCommitWarnings.get(toolCallId);
584
+ if (preCommitWarning) {
585
+ warnings.push(preCommitWarning);
586
+ }
587
+ pendingPreCommitWarnings.delete(toolCallId);
588
+ return { warnings, changed };
589
+ }
590
+
591
+ /**
592
+ * Complete a code-review loop from a phase_ready({issuesFound, cannotFix}) call.
593
+ * The issue counts come directly from the fy-review skill. Records review
594
+ * history, tracks reviewer empty loops,
595
+ * then drives the loop decision + (UAT/finish) transition via handleReviewLoopEnd.
596
+ */
597
+ async function completeCodeReviewLoop(
598
+ ctx: ExtensionContext,
599
+ issuesFound: number,
600
+ cannotFixIssues: number,
601
+ falsePositives: number,
602
+ ): Promise<void> {
603
+ const ws = handler.getWorkflowState();
604
+ if (ws?.currentPhase !== "review") return;
605
+
606
+ // NOTE: once-per-agent-turn dedup is owned by phase_ready's unified guard
607
+ // (phaseReadyPassed, checked at the top of execute + reset on agent_end).
608
+ // completeCodeReviewLoop is only reached on the first phase_ready this agent
609
+ // turn, so no local guard is needed here.
610
+
611
+ const active = loadActiveFeatureState(handler);
612
+ if (!active) return;
613
+ const { slug, featureState } = active;
614
+ const currentLoop = featureState.review.reviewLoopCount ?? 0;
615
+
616
+ recordReviewHistory(featureState, {
617
+ phase: "review",
618
+ loopNumber: currentLoop,
619
+ issuesFound,
620
+ falsePositives,
621
+ cannotFixIssues,
622
+ });
623
+
624
+ await trackReviewerEmptyLoops({
625
+ slug,
626
+ currentLoop,
627
+ compaction,
628
+ zeroIssues: issuesFound === 0,
629
+ });
630
+
631
+ await handleReviewLoopEnd(ctx, {
632
+ slug,
633
+ featureState,
634
+ issuesFound,
635
+ cannotFixIssues,
636
+ logPrefix: issuesFound === 0 ? "review loop ended (zero issues)" : "review loop ended",
637
+ });
638
+ }
639
+
640
+ return {
641
+ onBashCall,
642
+ onWriteEditCall,
643
+ onPhaseReadyCall,
644
+ onTaskReadyAdvanceCall,
645
+ onReadResult,
646
+ onWriteEditResult,
647
+ onBashResult,
648
+ setVerifyTestsPassed(passed: boolean) {
649
+ verifyTestsPassed = passed;
650
+ },
651
+ isVerifyTestsPassed() {
652
+ return verifyTestsPassed;
653
+ },
654
+ resetTracking() {
655
+ pendingViolations.clear();
656
+ pendingPreCommitWarnings.clear();
657
+ pendingProcessWarnings.clear();
658
+ guardrailTracker.reset();
659
+ verifyTestsPassed = false;
660
+ },
661
+ completeCodeReviewLoop,
662
+ };
663
+ }