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,217 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Review loop logic — deciding whether to continue review, generating reports,
6
+ * and transitioning to UAT/finish after review completion.
7
+ */
8
+
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { NO_COMPACT_CALLBACK, triggerContextCompact } from "../compaction/compact-trigger.js";
11
+ import type { AutoAgentCallback } from "../kanban/auto-agent/auto-agent-state-machine.js";
12
+ import { log } from "../log.js";
13
+ import { syncEnvVarsFromState } from "../phases/env-sync.js";
14
+ import { transitionToFinishPhase, transitionToUatPhase } from "../phases/phase-transitions.js";
15
+ import { toRouteConfig } from "../phases/workflow-router.js";
16
+ import { type FeatyardSettings, getSettings, resolveReviewSkill } from "../settings/settings-ui.js";
17
+ import { NO_AGENT_NAME, NO_FEATURE_STATE_OVERRIDE } from "../shared/workflow-refs.js";
18
+ import type { FeatureSession } from "../state/feature-session.js";
19
+ import { DEFAULT_DIR, type ExpandSkillCommandFn, type FeatureState, saveFeatureState } from "../state/feature-state.js";
20
+ import { schedulePostTurnFollowUp } from "../state/post-turn-dispatch.js";
21
+ import { worthNotesPointerFor } from "../state/worth-notes.js";
22
+ import { NO_FEATURE_STATE, updateWidget } from "../ui/featyard-widget.js";
23
+ import { generateReviewReport } from "./review-report.js";
24
+
25
+ /** Sentinel for "no review-report context to merge" at the off/after-finish UAT paths and the
26
+ * non-loop call sites (manual /fy:next, verify re-entry). Required (no-optional-params convention);
27
+ * pass this instead of a bare `null`. */
28
+ export const NO_REVIEW_CONTEXT: {
29
+ report: string | null;
30
+ level: "info" | "warning";
31
+ pointer: string | null;
32
+ } | null = null;
33
+
34
+ /**
35
+ * Decide whether a review loop should continue based on settings and results.
36
+ */
37
+ export function resolveReviewLoopDecision(
38
+ settings: { reviewLoops: number; minReviewLoops: number },
39
+ currentLoop: number,
40
+ issuesFound: number,
41
+ ): { shouldLoop: boolean } {
42
+ const numericMax = settings.reviewLoops;
43
+ const numericMin = settings.minReviewLoops;
44
+ const effectiveMax = Math.max(numericMax, numericMin);
45
+ const loopsCompleted = currentLoop + 1;
46
+ const minMet = loopsCompleted >= numericMin;
47
+ const shouldLoop = numericMax !== 0 && (issuesFound > 0 || !minMet) && loopsCompleted < effectiveMax;
48
+ return { shouldLoop };
49
+ }
50
+
51
+ /** Dependencies injected from the factory closure */
52
+ export interface ReviewLoopDeps {
53
+ handler: FeatureSession;
54
+ expandSkillCommand: ExpandSkillCommandFn;
55
+ applyModelOverrideForPhase: (pi: ExtensionAPI, ctx: ExtensionContext, stage: string) => Promise<void>;
56
+ getAutoAgentCallback: () => AutoAgentCallback | null;
57
+ /** Recover a failed ctx.compact — injected from the compaction module. */
58
+ recoverCompactFailure: () => void;
59
+ pi: ExtensionAPI;
60
+ }
61
+
62
+ export function createReviewLoopHandlers(deps: ReviewLoopDeps) {
63
+ const { handler, expandSkillCommand, applyModelOverrideForPhase, getAutoAgentCallback, recoverCompactFailure, pi } =
64
+ deps;
65
+
66
+ async function handleReviewLoopEnd(
67
+ ctx: ExtensionContext,
68
+ opts: {
69
+ slug: string;
70
+ featureState: FeatureState;
71
+ issuesFound: number;
72
+ cannotFixIssues: number;
73
+ logPrefix: string;
74
+ },
75
+ ): Promise<void> {
76
+ const { slug, featureState, issuesFound, cannotFixIssues, logPrefix } = opts;
77
+ const settings = getSettings();
78
+ const currentLoop = featureState.review.reviewLoopCount ?? 0;
79
+ const { shouldLoop } = resolveReviewLoopDecision(
80
+ { reviewLoops: settings.maxFeatureReviewRounds, minReviewLoops: settings.minReviewLoops },
81
+ currentLoop,
82
+ issuesFound,
83
+ );
84
+ log.info(
85
+ `[workflow] DIAGNOSTICS: handleReviewLoopEnd slug=${slug}, currentLoop=${currentLoop}, issuesFound=${issuesFound}, maxFeatureReviewRounds=${settings.maxFeatureReviewRounds}, minReviewLoops=${settings.minReviewLoops}, shouldLoop=${shouldLoop}`,
86
+ );
87
+
88
+ if (shouldLoop) {
89
+ featureState.review.reviewLoopCount = currentLoop + 1;
90
+ saveFeatureState(featureState, DEFAULT_DIR);
91
+ syncEnvVarsFromState(handler);
92
+ updateWidget(handler, NO_FEATURE_STATE);
93
+ const reviewSkill = resolveReviewSkill(settings);
94
+ log.info(`[workflow] DIAGNOSTICS: resolveReviewSkill returned=${reviewSkill}`);
95
+ if (reviewSkill) {
96
+ // Compact between review loops (reviewIterationCompact). triggerContextCompact
97
+ // re-injects the phase skill (fy-review) after compaction; if it doesn't
98
+ // compact (none / below threshold), fall back to re-dispatching directly.
99
+ const compacted = await triggerContextCompact(
100
+ ctx,
101
+ {
102
+ settingValue: settings.reviewIterationCompact,
103
+ skillName: reviewSkill,
104
+ message: `Run code review iteration #${currentLoop + 1}`,
105
+ logLabel: "code-review loop compact",
106
+ },
107
+ NO_COMPACT_CALLBACK,
108
+ recoverCompactFailure,
109
+ );
110
+ if (!compacted) {
111
+ const skillText = expandSkillCommand(`/skill:${reviewSkill}`, NO_FEATURE_STATE_OVERRIDE, NO_AGENT_NAME);
112
+ schedulePostTurnFollowUp(skillText);
113
+ }
114
+ }
115
+ } else {
116
+ // Loop ends — clean up and transition
117
+ saveFeatureState(featureState, DEFAULT_DIR);
118
+
119
+ // Generate the review report and its level (always computed — needed by the after-review
120
+ // MERGE below even in headless mode; level is hoisted above the hasUI guard for that).
121
+ const report = generateReviewReport(featureState);
122
+ const level: "info" | "warning" = cannotFixIssues > 0 ? "warning" : "info";
123
+ // Worth-notes pointer (existence + path) for the active feature — merged into the boundary
124
+ // notify so it never stands alone (notifications are exclusive). Absent/empty → null.
125
+ const pointer = worthNotesPointerFor(slug);
126
+
127
+ // uatMode after-review: the report + UAT-handoff + worth-notes pointer are MERGED into ONE
128
+ // notification — the standalone report notify is suppressed on this path so the
129
+ // handoff doesn't hide it (pre-existing data-loss bug). off/after-finish keep the standalone
130
+ // report notify (the natural review-completion boundary) with the worth-notes pointer appended.
131
+ const guard = globalThis.__piCtx;
132
+ const hasUi = guard?.hasUI && guard?.ui?.notify;
133
+ if (settings.uatMode !== "after-review" && hasUi) {
134
+ guard.ui.notify(pointer ? `${report}\n\n${pointer}` : report, level);
135
+ }
136
+ // Always log the report in headless mode (or after-review, where the standalone notify is
137
+ // suppressed in favor of the merged handoff) so the review stats remain observable there.
138
+ if (!hasUi) {
139
+ log.info(`[workflow] ${logPrefix} — report: ${report}`);
140
+ }
141
+
142
+ // Transition to UAT based on uatMode setting. For after-review, thread the report + level
143
+ // + pointer so handleReviewToUatTransition builds the combined notify (report + handoff +
144
+ // pointer) in ONE notification with the report's level.
145
+ await handleReviewToUatTransition(ctx, slug, settings, { report, level, pointer });
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Handle transition from completed review phase to UAT/finish based on uatMode.
151
+ * Called after review loop ends (shouldLoop=false) or zero-issues review.
152
+ */
153
+ async function handleReviewToUatTransition(
154
+ ctx: ExtensionContext,
155
+ slug: string,
156
+ settings: FeatyardSettings,
157
+ // Optional review-report context. Provided by handleReviewLoopEnd on the
158
+ // after-review path so the report + UAT-handoff + worth-notes pointer MERGE into ONE notify
159
+ // (notifications are exclusive — otherwise the handoff hides the report). null at the
160
+ // off/after-finish paths and the non-loop call sites (no merge there) — required per the
161
+ // project's no-optional-params convention; explicit null passed from those call sites.
162
+ reportCtx: { report: string | null; level: "info" | "warning"; pointer: string | null } | null,
163
+ ): Promise<void> {
164
+ const uatMode = settings.uatMode;
165
+ log.info(`[workflow] handleReviewToUatTransition: uatMode=${uatMode}, slug=${slug}`);
166
+
167
+ // Complete the review phase FIRST
168
+ handler.completeCurrentWorkflowPhase(toRouteConfig(settings)); // marks review as "complete"
169
+
170
+ // Load feature state once — each branch syncs handler workflow state into it
171
+ const featureState = handler.getActiveFeatureState();
172
+
173
+ if (uatMode === "off") {
174
+ // Skip UAT, auto-proceed to finish
175
+ handler.setCurrentPhase("finish"); // skip
176
+ await transitionToFinishPhase(featureState, { pi, ctx, handler, applyModelOverrideForPhase, expandSkillCommand });
177
+ } else if (uatMode === "after-review") {
178
+ // Pause at UAT — user advances via /fy:next (works in place, then advances).
179
+ // MERGE the review report + UAT-handoff + worth-notes pointer into ONE notification (design
180
+ // ): the report notify was already suppressed in handleReviewLoopEnd for this path,
181
+ // so the combined message here is the single boundary notify that carries everything. The
182
+ // report's level (warning if cannot-fix) is preserved via notifyLevel.
183
+ const handoffMsg = `Feature "${slug}" is ready for UAT. Work in place, then /fy:next to advance.`;
184
+ const report = reportCtx?.report;
185
+ const pointer = reportCtx?.pointer;
186
+ const notifyMessage =
187
+ report != null
188
+ ? pointer
189
+ ? `${report}\n\n${handoffMsg}\n${pointer}`
190
+ : `${report}\n\n${handoffMsg}`
191
+ : pointer
192
+ ? `${handoffMsg}\n${pointer}`
193
+ : handoffMsg;
194
+ await transitionToUatPhase(
195
+ ctx,
196
+ slug,
197
+ featureState,
198
+ {
199
+ pi,
200
+ handler,
201
+ applyModelOverrideForPhase,
202
+ getAutoAgentCallback,
203
+ },
204
+ {
205
+ kanbanNote: "review complete — UAT handoff",
206
+ notifyMessage,
207
+ notifyLevel: reportCtx?.level,
208
+ },
209
+ );
210
+ } else if (uatMode === "after-finish") {
211
+ // Proceed to finish; after-finish UAT is driven by the derived check in phase_ready.
212
+ await transitionToFinishPhase(featureState, { pi, ctx, handler, applyModelOverrideForPhase, expandSkillCommand });
213
+ }
214
+ }
215
+
216
+ return { handleReviewLoopEnd, handleReviewToUatTransition };
217
+ }
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Review report generation.
6
+ *
7
+ * Produces a plain-text summary of review-round history from feature state.
8
+ *
9
+ * History partitioning: every entry carries a `phase` tag (design | plan | review).
10
+ * The report groups entries by phase (in phase order) and shows a per-phase subtotal,
11
+ * then overall totals. Entries that predate phase tagging (legacy) lack the tag and
12
+ * are excluded entirely — they cannot be reliably attributed to a phase.
13
+ *
14
+ * NOTE: the report is delivered via `ui.notify`, which renders PLAIN TEXT (a single
15
+ * Text component, no markdown parsing). Do not use markdown markers (##, **, |, -).
16
+ */
17
+
18
+ import type { FeatureState, ReviewHistoryEntry, ReviewPhase } from "../state/feature-state.js";
19
+
20
+ const PHASE_ORDER: ReviewPhase[] = ["design", "plan", "review"];
21
+
22
+ const PHASE_LABEL: Record<ReviewPhase, string> = {
23
+ design: "Design Review",
24
+ plan: "Plan Review",
25
+ review: "Code Review",
26
+ };
27
+
28
+ /** A tagged entry belongs to a known review phase. Legacy entries (no phase) are excluded. */
29
+ function isTaggedEntry(entry: ReviewHistoryEntry): entry is ReviewHistoryEntry & { phase: ReviewPhase } {
30
+ return entry.phase === "design" || entry.phase === "plan" || entry.phase === "review";
31
+ }
32
+
33
+ /**
34
+ * Entries that carry a phase tag and thus appear in reports. Legacy untagged entries
35
+ * (from before phase tagging) are excluded — they cannot be reliably attributed to a
36
+ * phase and would misattribute stats. Shared by the report and the cannot-fix level scan
37
+ * so they stay consistent.
38
+ */
39
+ export function getReportableReviewHistory(featureState: FeatureState): ReviewHistoryEntry[] {
40
+ return (featureState.review.reviewHistory ?? []).filter(isTaggedEntry);
41
+ }
42
+
43
+ /** `1 issue` / `2 issues`, `1 false positive` / `2 false positives`. */
44
+ function plural(n: number, singular: string, pluralForm: string): string {
45
+ return n === 1 ? singular : pluralForm;
46
+ }
47
+
48
+ /** Format one count line: `5 issues found, 1 false positive, 2 cannot-fix`. */
49
+ function formatCounts(issues: number, falsePositives: number, cannotFix: number): string {
50
+ return (
51
+ `${issues} ${plural(issues, "issue", "issues")} found, ` +
52
+ `${falsePositives} ${plural(falsePositives, "false positive", "false positives")}, ` +
53
+ `${cannotFix} cannot-fix`
54
+ );
55
+ }
56
+
57
+ export function generateReviewReport(featureState: FeatureState): string {
58
+ const history = getReportableReviewHistory(featureState);
59
+ const lines: string[] = ["Review Round Summary"];
60
+
61
+ let totalIssues = 0;
62
+ let totalFalsePositives = 0;
63
+ let totalCannotFix = 0;
64
+
65
+ for (const phase of PHASE_ORDER) {
66
+ const entries = history.filter((e) => e.phase === phase);
67
+ if (entries.length === 0) continue;
68
+
69
+ const label = PHASE_LABEL[phase];
70
+ lines.push("");
71
+ lines.push(label);
72
+
73
+ let phaseIssues = 0;
74
+ let phaseFalsePositives = 0;
75
+ let phaseCannotFix = 0;
76
+ for (const entry of entries) {
77
+ lines.push(
78
+ ` Round #${entry.loopNumber}: ${formatCounts(entry.issuesFound, entry.falsePositives, entry.cannotFixIssues)}`,
79
+ );
80
+ phaseIssues += entry.issuesFound;
81
+ phaseFalsePositives += entry.falsePositives;
82
+ phaseCannotFix += entry.cannotFixIssues;
83
+ }
84
+ lines.push(` ${label} total: ${formatCounts(phaseIssues, phaseFalsePositives, phaseCannotFix)}`);
85
+
86
+ totalIssues += phaseIssues;
87
+ totalFalsePositives += phaseFalsePositives;
88
+ totalCannotFix += phaseCannotFix;
89
+ }
90
+
91
+ lines.push("");
92
+ lines.push(
93
+ `Totals: ${totalIssues} ${plural(totalIssues, "issue", "issues")} found across ${history.length} ${plural(history.length, "round", "rounds")}`,
94
+ );
95
+ lines.push(` False positives: ${totalFalsePositives}`);
96
+ if (totalCannotFix > 0) {
97
+ lines.push(` ⚠️ Cannot fix: ${totalCannotFix}`);
98
+ }
99
+
100
+ return lines.join("\n");
101
+ }
@@ -0,0 +1,269 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Featyard-specific model override configuration.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { log } from "../log.js";
12
+ // (no pi-tui or avtc-pi-settings-ui imports needed — model overrides are not rendered as a settings tab)
13
+
14
+ export type ModelOverride = string | string[];
15
+
16
+ export interface FeatyardConfig {
17
+ "stage-models"?: Record<string, ModelOverride>;
18
+ "default-model": string | null;
19
+ "kanban-port"?: number | null;
20
+ /** File extensions counted as "source" files by the pre-commit/coverage gate (e.g. [".ts",".py"], ["+.md","-.css"]). null = use built-in defaults. */
21
+ "source-extensions"?: string[] | null;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Validation helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function isValidModelString(val: unknown): val is string {
29
+ if (typeof val !== "string") return false;
30
+ const idx = val.indexOf("/");
31
+ return idx > 0 && idx < val.length - 1;
32
+ }
33
+
34
+ function validateDefaultModel(val: unknown): string | null {
35
+ if (val == null) return null;
36
+ if (isValidModelString(val)) return val;
37
+ log.warn(`Skipping invalid default-model: ${JSON.stringify(val)}`);
38
+ return null;
39
+ }
40
+
41
+ function validateKanbanPort(val: unknown): number | null {
42
+ if (val == null) return null;
43
+ if (typeof val === "number" && Number.isInteger(val) && val >= 1 && val <= 65535) return val;
44
+ log.warn(`Skipping invalid kanban-port: ${JSON.stringify(val)} (must be integer 1-65535 or null)`);
45
+ return null;
46
+ }
47
+
48
+ /** Validate a source-extensions entry: must be a non-empty array of strings, else null. */
49
+ function validateSourceExtensions(val: unknown): string[] | null {
50
+ if (val == null) return null;
51
+ if (Array.isArray(val) && val.every((v) => typeof v === "string")) {
52
+ return val.filter((v) => v.trim().length > 0);
53
+ }
54
+ log.warn(`Skipping invalid source-extensions: ${JSON.stringify(val)} (must be array of strings or null)`);
55
+ return null;
56
+ }
57
+
58
+ function validateOverride(val: unknown): ModelOverride | null {
59
+ if (isValidModelString(val)) return val;
60
+ if (Array.isArray(val)) {
61
+ const valid = val.filter((v) => isValidModelString(v));
62
+ return valid.length > 0 ? valid : null;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function filterValidOverrides(globalRaw: unknown, projectRaw: unknown): Record<string, ModelOverride> {
68
+ const result: Record<string, ModelOverride> = {};
69
+ for (const raw of [globalRaw, projectRaw]) {
70
+ if (typeof raw !== "object" || raw === null) continue;
71
+ for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
72
+ const validated = validateOverride(val);
73
+ if (validated) {
74
+ result[key] = validated;
75
+ } else if (val !== undefined) {
76
+ log.warn(`Skipping invalid model override '${key}': ${JSON.stringify(val)}`);
77
+ }
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+
83
+ function extractFeatyardSection(raw: Record<string, unknown> | null): Record<string, unknown> | null {
84
+ if (!raw) return null;
85
+ const sp = raw["avtc-pi-featyard"];
86
+ if (!sp || typeof sp !== "object") return null;
87
+ return sp as Record<string, unknown>;
88
+ }
89
+
90
+ function readJsonFile(filePath: string): Record<string, unknown> | null {
91
+ try {
92
+ if (!existsSync(filePath)) return null;
93
+ const raw = readFileSync(filePath, "utf-8");
94
+ return JSON.parse(raw) as Record<string, unknown>;
95
+ } catch (err) {
96
+ log.warn(`readJsonFile failed for ${filePath}: ${err instanceof Error ? err.message : err}`);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Config loading
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function emptyConfig(): Required<FeatyardConfig> {
106
+ return {
107
+ "stage-models": {},
108
+ "default-model": null,
109
+ "kanban-port": null,
110
+ "source-extensions": null,
111
+ };
112
+ }
113
+
114
+ let _config: Required<FeatyardConfig> = emptyConfig();
115
+ let _configLoaded = false;
116
+ /** Serialized snapshot of the last loaded config, used to detect changes. */
117
+ let _configSnapshot: string | null = null;
118
+
119
+ function getDefaultGlobalDir(): string {
120
+ return join(homedir(), ".pi");
121
+ }
122
+
123
+ /** Read featyard sections from global and project config files. */
124
+ function readConfigSections(
125
+ globalDir: string | null,
126
+ cwd: string | null,
127
+ ): {
128
+ globalSection: Record<string, unknown> | null;
129
+ projectSection: Record<string, unknown> | null;
130
+ } {
131
+ const globalPath = join(globalDir ?? getDefaultGlobalDir(), "agent", "settings.json");
132
+ const globalSection = extractFeatyardSection(readJsonFile(globalPath));
133
+
134
+ let projectSection: Record<string, unknown> | null = null;
135
+ if (cwd) {
136
+ const projectPath = join(cwd, ".pi", "settings.json");
137
+ projectSection = extractFeatyardSection(readJsonFile(projectPath));
138
+ }
139
+
140
+ return { globalSection, projectSection };
141
+ }
142
+
143
+ /** Pass as `cwd` when no working directory override is needed. */
144
+ /** Sentinel: no working directory override (use process.cwd()) */
145
+ export const NO_CWD_OVERRIDE: string | null = null;
146
+
147
+ /** Sentinel: no working directory specified (use process.cwd()) */
148
+ export const NO_CWD: string | null = null;
149
+
150
+ /** Sentinel: use the default global directory (the shared ~/.pi/agent/settings.json). */
151
+ export const DEFAULT_GLOBAL_DIR: string | null = null;
152
+
153
+ export function loadFeatyardConfig(globalDir: string | null, cwd: string | null): Required<FeatyardConfig> {
154
+ if (_configLoaded) return _config;
155
+
156
+ const { globalSection, projectSection } = readConfigSections(globalDir, cwd);
157
+
158
+ if (!globalSection && !projectSection) {
159
+ _config = emptyConfig();
160
+ _configLoaded = true;
161
+ _configSnapshot = JSON.stringify({ global: null, project: null });
162
+ return _config;
163
+ }
164
+
165
+ const globalModels = globalSection ?? {};
166
+ const projectModels = projectSection ?? {};
167
+
168
+ _config = {
169
+ "stage-models": filterValidOverrides(globalModels["stage-models"], projectModels["stage-models"]),
170
+ "default-model": validateDefaultModel(
171
+ "default-model" in projectModels ? projectModels["default-model"] : globalModels["default-model"],
172
+ ),
173
+ "kanban-port": validateKanbanPort(
174
+ "kanban-port" in projectModels ? projectModels["kanban-port"] : globalModels["kanban-port"],
175
+ ),
176
+ "source-extensions": validateSourceExtensions(
177
+ "source-extensions" in projectModels ? projectModels["source-extensions"] : globalModels["source-extensions"],
178
+ ),
179
+ };
180
+
181
+ _configLoaded = true;
182
+ _configSnapshot = JSON.stringify({ global: globalSection, project: projectSection });
183
+ log.info(
184
+ `Featyard config loaded: ${Object.keys(_config["stage-models"] ?? {}).length} stage-models, default-model=${_config["default-model"] ?? "<none>"}, kanban-port=${_config["kanban-port"] ?? "random"}, source-extensions=${_config["source-extensions"] ? "custom" : "default"}`,
185
+ );
186
+ return _config;
187
+ }
188
+
189
+ export function resetFeatyardConfig(): void {
190
+ _config = emptyConfig();
191
+ _configLoaded = false;
192
+ _configSnapshot = null;
193
+ }
194
+
195
+ export function setFeatyardConfig(config: Required<FeatyardConfig>): void {
196
+ _config = config;
197
+ _configLoaded = true;
198
+ }
199
+
200
+ export function invalidateConfigCache(): void {
201
+ _configLoaded = false;
202
+ }
203
+
204
+ /**
205
+ * Invalidate config cache only if the model-related config section actually changed.
206
+ * Reads the current config from disk and compares to the cached snapshot.
207
+ * This avoids unnecessary re-reads when only non-model settings were saved.
208
+ */
209
+ export function invalidateConfigCacheIfChanged(globalDir: string | null, cwd: string | null): void {
210
+ if (!_configLoaded) return; // Already invalidated — nothing to check
211
+
212
+ const { globalSection, projectSection } = readConfigSections(globalDir, cwd);
213
+ const currentSnapshot = JSON.stringify({ global: globalSection, project: projectSection });
214
+ if (currentSnapshot !== _configSnapshot) {
215
+ _configLoaded = false;
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Model resolution
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function resolveOverride(override: ModelOverride, loopIndex: number): string | null {
224
+ if (Array.isArray(override)) {
225
+ if (override.length === 0) return null;
226
+ const idx = ((loopIndex % override.length) + override.length) % override.length;
227
+ return override[idx] ?? null;
228
+ }
229
+ return override;
230
+ }
231
+
232
+ export function resolveModelOverride(stage: string | null, loopIndex: number, config: FeatyardConfig): string | null {
233
+ // Workflow callers (phase-transitions, review-context): stage-models -> default-model.
234
+ // If a stage-models entry EXISTS for the active stage, return its resolution
235
+ // (even if it resolves to null, e.g. an empty array) — do NOT fall through to
236
+ // default-model. default-model applies only when no entry exists for the stage.
237
+ // The entry is captured once (avoids double lookup) and the truthy guard mirrors
238
+ // the original semantics (arrays are truthy; empty/missing entries skip).
239
+ const entry = stage ? config["stage-models"]?.[stage] : undefined;
240
+ if (entry) return resolveOverride(entry, loopIndex);
241
+ // Truthy check (not ??) preserves original behavior: an empty-string
242
+ // default-model is treated as absent (validated configs never have "", but
243
+ // raw configs passed directly could).
244
+ if (config["default-model"]) return config["default-model"];
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * Stage-models only, NO default-model fallthrough. Used by the subagent hook so it
250
+ * yields to pi-subagent's Phase 3 default instead of shadowing it.
251
+ */
252
+ export function resolveStageModelOnly(stage: string | null, loopIndex: number, config: FeatyardConfig): string | null {
253
+ const entry = stage ? config["stage-models"]?.[stage] : undefined;
254
+ if (entry) return resolveOverride(entry, loopIndex);
255
+ return null;
256
+ }
257
+
258
+ export function resolveReviewSkill(settings: { maxFeatureReviewRounds: number }): string | null {
259
+ if (settings.maxFeatureReviewRounds === 0) return null;
260
+ return "fy-review";
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Test helpers
265
+ // ---------------------------------------------------------------------------
266
+
267
+ export function _resetModelResolution(): void {
268
+ _configLoaded = false;
269
+ }