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.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/agents/fy-design-reviewer.md +77 -0
- package/agents/fy-feature-verifier.md +24 -0
- package/agents/fy-general-reviewer.md +81 -0
- package/agents/fy-guidelines-reviewer.md +46 -0
- package/agents/fy-implementer.md +20 -0
- package/agents/fy-performance-reviewer.md +46 -0
- package/agents/fy-plan-reviewer.md +74 -0
- package/agents/fy-plan-verifier.md +44 -0
- package/agents/fy-quality-reviewer.md +50 -0
- package/agents/fy-requirements-reviewer.md +46 -0
- package/agents/fy-researcher.md +6 -0
- package/agents/fy-security-reviewer.md +46 -0
- package/agents/fy-task-verifier.md +42 -0
- package/agents/fy-testing-reviewer.md +60 -0
- package/assets/images/kanban-board.png +0 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/assets/images/widget.png +0 -0
- package/docs/CONFIGURATION.md +266 -0
- package/index.ts +8 -0
- package/package.json +85 -0
- package/skills/fy-design/SKILL.md +108 -0
- package/skills/fy-design-review/SKILL.md +45 -0
- package/skills/fy-finish/SKILL.md +28 -0
- package/skills/fy-implement/SKILL.md +36 -0
- package/skills/fy-plan/SKILL.md +77 -0
- package/skills/fy-plan-review/SKILL.md +45 -0
- package/skills/fy-research/SKILL.md +38 -0
- package/skills/fy-review/SKILL.md +88 -0
- package/skills/fy-verify/SKILL.md +23 -0
- package/src/commands/auto-agent-commands.ts +748 -0
- package/src/commands/kanban-commands.ts +278 -0
- package/src/commands/workflow-commands.ts +531 -0
- package/src/compaction/compact-handler.ts +371 -0
- package/src/compaction/compact-message.ts +52 -0
- package/src/compaction/compact-trigger.ts +121 -0
- package/src/compaction/safe-editor-write.ts +18 -0
- package/src/events/agent/agent-end.ts +19 -0
- package/src/events/agent/agent-settled.ts +20 -0
- package/src/events/agent/agent-start.ts +18 -0
- package/src/events/agent/turn-end.ts +20 -0
- package/src/events/agent/turn-start.ts +19 -0
- package/src/events/event-deps.ts +33 -0
- package/src/events/index.ts +91 -0
- package/src/events/input/input.ts +143 -0
- package/src/events/session/context.ts +68 -0
- package/src/events/session/model-select.ts +20 -0
- package/src/events/session/session-compact.ts +20 -0
- package/src/events/session/session-shutdown.ts +26 -0
- package/src/events/session/session-start.ts +45 -0
- package/src/events/session/session-tree.ts +17 -0
- package/src/events/tool/tool-call.ts +58 -0
- package/src/events/tool/tool-result.ts +54 -0
- package/src/git/git-queries.ts +154 -0
- package/src/git/resolve-base-branch.ts +173 -0
- package/src/git/worktrees/worktree-helpers.ts +142 -0
- package/src/git/worktrees/worktree-interception.ts +66 -0
- package/src/git/worktrees/worktree-lifecycle.ts +380 -0
- package/src/guardrails/completion-gating.ts +63 -0
- package/src/guardrails/file-classifier.ts +461 -0
- package/src/guardrails/force-add-guard.ts +312 -0
- package/src/guardrails/guardrail-tracker.ts +73 -0
- package/src/guardrails/guardrail-violations.ts +47 -0
- package/src/guardrails/guardrails-engine.ts +663 -0
- package/src/guardrails/session-guardrails.ts +75 -0
- package/src/guardrails/shell-decompose.ts +285 -0
- package/src/guardrails/tdd-enforcement.ts +122 -0
- package/src/guardrails/test-output.ts +272 -0
- package/src/index.ts +598 -0
- package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
- package/src/integrations/subagent-integration.ts +166 -0
- package/src/integrations/todo-integration.ts +58 -0
- package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
- package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
- package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
- package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
- package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
- package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
- package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
- package/src/kanban/data/kanban-database.ts +851 -0
- package/src/kanban/data/kanban-db-schema.ts +137 -0
- package/src/kanban/data/kanban-detect-project.ts +43 -0
- package/src/kanban/data/kanban-move-feature.ts +43 -0
- package/src/kanban/data/kanban-types.ts +64 -0
- package/src/kanban/ensure-feature.ts +226 -0
- package/src/kanban/kanban-board-ui/app.js +1125 -0
- package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
- package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
- package/src/kanban/kanban-board-ui/index.html +127 -0
- package/src/kanban/kanban-board-ui/style.css +820 -0
- package/src/kanban/kanban-bridge.ts +216 -0
- package/src/kanban/kanban-context.ts +43 -0
- package/src/kanban/kanban-events.ts +34 -0
- package/src/kanban/kanban-generate-title.ts +259 -0
- package/src/kanban/kanban-generate-topic.ts +170 -0
- package/src/kanban/kanban-operations.ts +170 -0
- package/src/kanban/kanban-server.ts +931 -0
- package/src/kanban/kanban-turn-handlers.ts +55 -0
- package/src/kanban/model-capture.ts +34 -0
- package/src/log.ts +47 -0
- package/src/phases/env-sync.ts +64 -0
- package/src/phases/execution-mode.ts +79 -0
- package/src/phases/implement-to-verify.ts +31 -0
- package/src/phases/phase-progression.ts +249 -0
- package/src/phases/phase-transitions.ts +226 -0
- package/src/phases/workflow-router.ts +98 -0
- package/src/prompts/finish-instructions.ts +143 -0
- package/src/prompts/skill-block-builder.ts +73 -0
- package/src/prompts/skill-expansion.ts +262 -0
- package/src/prompts/task-gate-skill.ts +83 -0
- package/src/prompts/template-engine.ts +433 -0
- package/src/prompts/text-blocks.ts +279 -0
- package/src/review/review-context.ts +56 -0
- package/src/review/review-counter.ts +66 -0
- package/src/review/review-empty-loop-tracking.ts +52 -0
- package/src/review/review-loops.ts +217 -0
- package/src/review/review-report.ts +101 -0
- package/src/settings/model-overrides.ts +269 -0
- package/src/settings/settings-schema.ts +458 -0
- package/src/settings/settings-types.ts +81 -0
- package/src/settings/settings-ui.ts +222 -0
- package/src/shared/errors.ts +24 -0
- package/src/shared/global.d.ts +22 -0
- package/src/shared/session-entries.ts +24 -0
- package/src/shared/types.ts +195 -0
- package/src/shared/workflow-refs.ts +253 -0
- package/src/shared/workflow-types.ts +180 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
- package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
- package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
- package/src/state/archive-artifacts.ts +602 -0
- package/src/state/artifact-junction.ts +462 -0
- package/src/state/artifact-paths.ts +230 -0
- package/src/state/feature-activation.ts +239 -0
- package/src/state/feature-management.ts +325 -0
- package/src/state/feature-record-store.ts +39 -0
- package/src/state/feature-session.ts +263 -0
- package/src/state/feature-state.ts +439 -0
- package/src/state/post-turn-dispatch.ts +106 -0
- package/src/state/session-lifecycle.ts +346 -0
- package/src/state/state-persistence.ts +122 -0
- package/src/state/worth-notes.ts +89 -0
- package/src/tools/add-to-backlog.ts +117 -0
- package/src/tools/phase-ready.ts +742 -0
- package/src/tools/task-ready-advance.ts +235 -0
- package/src/tools/text-result.ts +15 -0
- package/src/ui/featyard-widget.ts +250 -0
- package/src/ui/manage-features-dialog.ts +246 -0
- 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
|
+
}
|