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,166 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subagent integration layer — connects featyard-specific
|
|
6
|
+
* dependencies to the generic pi-subagent extension.
|
|
7
|
+
*
|
|
8
|
+
* This is the ONLY file that imports from both pi-subagent (via vendored drop-in)
|
|
9
|
+
* and featyard internals (settings, template-substitution, logging).
|
|
10
|
+
*
|
|
11
|
+
* Exports initSubagentIntegration(pi) which calls subscribeToSubagent
|
|
12
|
+
* with featyard-specific hooks (transformPrompt, etc.).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { agentLoop } from "@earendil-works/pi-agent-core";
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { _kanbanModelRef } from "../kanban/kanban-bridge.js";
|
|
19
|
+
import { generateTopic } from "../kanban/kanban-generate-topic.js";
|
|
20
|
+
import { log } from "../log.js";
|
|
21
|
+
import { getLoopCountForPhase } from "../phases/phase-transitions.js";
|
|
22
|
+
import { substitutePlaceholders } from "../prompts/template-engine.js";
|
|
23
|
+
import { DEFAULT_GLOBAL_DIR, loadFeatyardConfig, resolveStageModelOnly } from "../settings/settings-ui.js";
|
|
24
|
+
import { getActiveFeatureSlug, getHandlerRef } from "../shared/workflow-refs.js";
|
|
25
|
+
import { subscribeToSubagent } from "../snippets/vendored/subscribe-to-subagent.js";
|
|
26
|
+
import { slugifyTaskDesignation } from "../state/artifact-paths.js";
|
|
27
|
+
|
|
28
|
+
/** The extension name featyard reports when registering agents via the pi-subagent
|
|
29
|
+
* addAgentsPaths API. Lets extension-provided name-collision messages (in avtc-pi-subagent)
|
|
30
|
+
* identify featyard as the contributor. */
|
|
31
|
+
const SUBAGENT_EXTENSION_NAME = "avtc-pi-featyard";
|
|
32
|
+
|
|
33
|
+
/** Fork context injected into an agent's system prompt when it runs in a forked
|
|
34
|
+
* (branched-session) subagent. Agent-specific entries take precedence over the
|
|
35
|
+
* generic fallback. Only the placeholder agent files reference this; resolution
|
|
36
|
+
* lives here because the `isFork` signal is only available on the subagent path. */
|
|
37
|
+
const FORK_CONTEXT_BY_AGENT: Record<string, string> = {
|
|
38
|
+
"fy-design-reviewer":
|
|
39
|
+
"Pay special attention to:\n" +
|
|
40
|
+
"- Decisions discussed but not captured in the final document\n" +
|
|
41
|
+
"- Implicit assumptions that are clear from conversation but missing from the doc\n" +
|
|
42
|
+
"- Changes in direction that weren't fully reflected",
|
|
43
|
+
"fy-plan-reviewer":
|
|
44
|
+
"Also check for:\n" + "- **Context gaps:** Discussed constraints or decisions not reflected in the plan",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Generic fork-context fallback for agents that have the
|
|
48
|
+
* `{{PI_FY_FORK_CONTEXT_INJECTION}}` placeholder but no dedicated entry above. */
|
|
49
|
+
const FORK_CONTEXT_FALLBACK =
|
|
50
|
+
"Pay special attention to:\n" +
|
|
51
|
+
"- Decisions discussed but not captured in the written documents\n" +
|
|
52
|
+
"- Implicit assumptions clear from conversation but missing from formal output";
|
|
53
|
+
|
|
54
|
+
/** Resolve the `{{PI_FY_FORK_CONTEXT_INJECTION}}` placeholder in an agent system prompt.
|
|
55
|
+
* Injects agent-specific (or fallback) bullets when forking; empty string otherwise. */
|
|
56
|
+
function resolveForkContextInjection(systemPrompt: string, agentName: string, isFork: boolean): string {
|
|
57
|
+
if (!systemPrompt.includes("{{PI_FY_FORK_CONTEXT_INJECTION}}")) return systemPrompt;
|
|
58
|
+
if (!isFork) {
|
|
59
|
+
return systemPrompt.replaceAll("{{PI_FY_FORK_CONTEXT_INJECTION}}", "");
|
|
60
|
+
}
|
|
61
|
+
const baseName = agentName.replace(/-fork$/, "");
|
|
62
|
+
const injection = FORK_CONTEXT_BY_AGENT[baseName] ?? FORK_CONTEXT_FALLBACK;
|
|
63
|
+
return systemPrompt.replaceAll("{{PI_FY_FORK_CONTEXT_INJECTION}}", injection);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the current plan-task designation (set by task_ready_advance) from the
|
|
68
|
+
* in-memory handler feature-state (the durable source of truth). Returns the raw task
|
|
69
|
+
* string (e.g. '3. Wire the login form'), or undefined when no task has been advanced to
|
|
70
|
+
* yet. Path builders slugify this value.
|
|
71
|
+
*/
|
|
72
|
+
function resolveCurrentTaskName(): string | undefined {
|
|
73
|
+
return getHandlerRef()?.getActiveFeatureState()?.implement.currentTask ?? undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the loop index for a subagent invocation.
|
|
78
|
+
*
|
|
79
|
+
* Three cases:
|
|
80
|
+
* 1. Per-task verify/review (fy-task-verifier or fy-general-reviewer during implement
|
|
81
|
+
* phase) — PURE READ of the task's taskReviewRounds on the active feature record.
|
|
82
|
+
* (task_ready_advance's gate is the sole incrementer; this only reads the current round
|
|
83
|
+
* to give each round a unique report file path.)
|
|
84
|
+
* 2. Review agents (name contains "review") — reads the phase-aware loop count from
|
|
85
|
+
* feature state (the durable source of truth).
|
|
86
|
+
* 3. All other agents — no loop index.
|
|
87
|
+
*/
|
|
88
|
+
export function _resolveReviewLoopIndex(
|
|
89
|
+
agentName: string,
|
|
90
|
+
_slug: string | undefined,
|
|
91
|
+
taskName: string | undefined,
|
|
92
|
+
): number | undefined {
|
|
93
|
+
const handler = getHandlerRef();
|
|
94
|
+
const featureState = handler?.getActiveFeatureState() ?? null;
|
|
95
|
+
const stage = handler?.getWorkflowState()?.currentPhase ?? null;
|
|
96
|
+
|
|
97
|
+
// Case 1: Per-task verify/review during implement phase — PURE READ.
|
|
98
|
+
// task_ready_advance's gate is the sole incrementer of taskReviewRounds; this function
|
|
99
|
+
// only reads the current round so per-task reports render numbered paths. Covers both
|
|
100
|
+
// fy-general-reviewer and fy-task-verifier.
|
|
101
|
+
if ((agentName === "fy-general-reviewer" || agentName === "fy-task-verifier") && taskName) {
|
|
102
|
+
if (stage === "implement" && featureState) {
|
|
103
|
+
const key = slugifyTaskDesignation(taskName);
|
|
104
|
+
return featureState.implement.taskReviewRounds[key] ?? 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Case 2: Review agents — read phase-aware loop count from feature state
|
|
109
|
+
if (agentName.includes("review") && stage) {
|
|
110
|
+
return getLoopCountForPhase(featureState, stage);
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function initSubagentIntegration(pi: ExtensionAPI): void {
|
|
116
|
+
subscribeToSubagent(
|
|
117
|
+
pi,
|
|
118
|
+
async (systemPrompt, context) => {
|
|
119
|
+
// Resolve slug, current plan-task designation, loopIndex from featyard context.
|
|
120
|
+
// All read from the in-memory handler feature-state (the durable source of truth);
|
|
121
|
+
// the only env var consulted here is PI_FY_FEATURE as a slug fallback when no
|
|
122
|
+
// handler is wired (the child loads its feature-state file from that slug on start).
|
|
123
|
+
const slug = getActiveFeatureSlug() ?? process.env.PI_FY_FEATURE;
|
|
124
|
+
const taskName = resolveCurrentTaskName();
|
|
125
|
+
const loopIndex = _resolveReviewLoopIndex(context.agentName, slug, taskName);
|
|
126
|
+
|
|
127
|
+
// Generate topic from task when no slug/feature is active
|
|
128
|
+
let topic: string | undefined;
|
|
129
|
+
if (!slug && context.task) {
|
|
130
|
+
try {
|
|
131
|
+
topic = await generateTopic(context.task, { agentLoop, modelRef: _kanbanModelRef });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.warn(`generateTopic failed: ${err instanceof Error ? err.message : err}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve FORK_CONTEXT_INJECTION first — needs context.isFork, only
|
|
138
|
+
// available on this subagent path (not in substitutePlaceholders).
|
|
139
|
+
systemPrompt = resolveForkContextInjection(systemPrompt, context.agentName, context.isFork);
|
|
140
|
+
|
|
141
|
+
return substitutePlaceholders(systemPrompt, {
|
|
142
|
+
agentName: context.agentName,
|
|
143
|
+
slug,
|
|
144
|
+
loopIndex,
|
|
145
|
+
taskName,
|
|
146
|
+
topic,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
() => {
|
|
150
|
+
// explicitModel is NOT consulted here: pi-subagent's Phase 0 short-circuits an
|
|
151
|
+
// explicit --model param before any Phase 2 hook runs, so this hook is only
|
|
152
|
+
// reached when explicitModel === undefined. This hook yields a stage-model
|
|
153
|
+
// (rotating by review-loop index) for the workflow's current stage, else
|
|
154
|
+
// undefined so pi-subagent's Phase 3 default-model applies.
|
|
155
|
+
const config = loadFeatyardConfig(DEFAULT_GLOBAL_DIR, process.cwd());
|
|
156
|
+
const handler = getHandlerRef();
|
|
157
|
+
const featureState = handler?.getActiveFeatureState() ?? null;
|
|
158
|
+
const stage = handler?.getWorkflowState()?.currentPhase ?? null;
|
|
159
|
+
const loopIdx = stage ? getLoopCountForPhase(featureState, stage) : 0;
|
|
160
|
+
return resolveStageModelOnly(stage, loopIdx, config) ?? undefined;
|
|
161
|
+
},
|
|
162
|
+
[path.resolve(__dirname, "..", "..", "skills")],
|
|
163
|
+
[path.resolve(__dirname, "..", "..", "agents")],
|
|
164
|
+
SUBAGENT_EXTENSION_NAME,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Feature-flow integration layer for pi-todo.
|
|
6
|
+
*
|
|
7
|
+
* Wires the generic pi-todo extension to featyard-specific infrastructure:
|
|
8
|
+
* - disableBuiltInFollowUp: featyard handles followUp via session_compact + the todo getters
|
|
9
|
+
*
|
|
10
|
+
* Exports initTodoIntegration(pi) which calls subscribeToTodo from vendored drop-in
|
|
11
|
+
* and returns the sync lazy proxy with getCompletedItemId/getInProgressItem/disableBuiltInFollowUp.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { subscribeToTodo } from "../snippets/vendored/subscribe-to-todo.js";
|
|
16
|
+
|
|
17
|
+
/** Module-level ref for todo API, populated by initTodoIntegration. */
|
|
18
|
+
let _todoApi: ReturnType<typeof subscribeToTodo> | null = null;
|
|
19
|
+
|
|
20
|
+
/** @internal Test override for areAllTodosDone — when set, takes precedence over _todoApi. */
|
|
21
|
+
let _areAllTodosDoneOverride: boolean | null = null;
|
|
22
|
+
|
|
23
|
+
/** Disable built-in followUp — featyard handles it via session_compact + the todo getters */
|
|
24
|
+
const DISABLE_BUILTIN_FOLLOW_UP = true;
|
|
25
|
+
|
|
26
|
+
export function initTodoIntegration(pi: ExtensionAPI): ReturnType<typeof subscribeToTodo> {
|
|
27
|
+
_todoApi = subscribeToTodo(pi, DISABLE_BUILTIN_FOLLOW_UP);
|
|
28
|
+
return _todoApi;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Id of the item just completed by the todo_complete that triggered the current compaction (consume-on-read),
|
|
32
|
+
* or null when this compaction was not triggered by item completion. Null if pi-todo is not loaded. */
|
|
33
|
+
export function getTodoCompletedItemId(): string | null {
|
|
34
|
+
return _todoApi?.getCompletedItemId() ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The current in-progress todo item formatted for followUp (`In progress: ▶ id: name\ndetails`),
|
|
38
|
+
* or null if none / pi-todo not loaded. */
|
|
39
|
+
export function getTodoInProgressItem(): string | null {
|
|
40
|
+
return _todoApi?.getInProgressItem() ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Returns true if all todo items are in a terminal state (completed or decomposed). True when no items. */
|
|
44
|
+
export function areAllTodosDone(): boolean {
|
|
45
|
+
if (_areAllTodosDoneOverride !== null) return _areAllTodosDoneOverride;
|
|
46
|
+
return _todoApi?.areAllTodosDone() ?? true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @internal Set override for areAllTodosDone (test only). Pass null to clear. */
|
|
50
|
+
export function _setAreAllTodosDoneOverride(value: boolean | null): void {
|
|
51
|
+
_areAllTodosDoneOverride = value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reset module-level state for test isolation. */
|
|
55
|
+
export function resetTodoIntegration(): void {
|
|
56
|
+
_todoApi = null;
|
|
57
|
+
_areAllTodosDoneOverride = null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Utility functions for auto-agent cleanup.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { log } from "../../log.js";
|
|
9
|
+
import type { GracePeriodManager } from "./auto-agent-grace-period.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Remove the stopped/error agent, clean up its timers,
|
|
13
|
+
* and remove the terminal input listener if no agent remains.
|
|
14
|
+
* Called with no arguments — reads from globalThis.__piKanban.autoAgent.
|
|
15
|
+
*/
|
|
16
|
+
export function cleanupStoppedAgents(): void {
|
|
17
|
+
const sm = globalThis.__piKanban?.autoAgent;
|
|
18
|
+
if (!sm) return;
|
|
19
|
+
if (sm.getState() === "stopped" || sm.getState() === "error") {
|
|
20
|
+
sm.stopHeartbeat();
|
|
21
|
+
sm.stopPollingTimer();
|
|
22
|
+
if (globalThis.__piKanban) globalThis.__piKanban.autoAgent = null;
|
|
23
|
+
log.info(`[kanban] cleaned up ${sm.getRole()} agent (state: ${sm.getState()})`);
|
|
24
|
+
// If this agent was in grace-period, stop the GPM immediately
|
|
25
|
+
const gpm = globalThis.__piKanban?.gracePeriod as GracePeriodManager | undefined;
|
|
26
|
+
if (gpm?.isActive()) gpm.stop();
|
|
27
|
+
}
|
|
28
|
+
// Clean up terminal input listener if no agent is running
|
|
29
|
+
const currentAgent = globalThis.__piKanban?.autoAgent;
|
|
30
|
+
if (!currentAgent || ["stopped", "error"].includes(currentAgent.getState())) {
|
|
31
|
+
const unsub = globalThis.__piKanban?.terminalInputUnsubscribe as (() => void) | undefined;
|
|
32
|
+
if (unsub) {
|
|
33
|
+
unsub();
|
|
34
|
+
if (globalThis.__piKanban) globalThis.__piKanban.terminalInputUnsubscribe = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Agent-lifecycle domain module — the agent_start and agent_end handler bodies
|
|
6
|
+
* as callable methods (no pi.on registration).
|
|
7
|
+
*
|
|
8
|
+
* agent_start re-arms the finish-phase guardrail whitelist (worktree policy +
|
|
9
|
+
* worktree path) at the start of every agent turn while finish is active, and
|
|
10
|
+
* signals a fresh turn to the compaction module. agent_end clears the guardrail,
|
|
11
|
+
* resets the once-per-turn phase_ready guard, flags the agent finished, and
|
|
12
|
+
* notifies the auto-agent on non-retryable execution-phase errors.
|
|
13
|
+
*
|
|
14
|
+
* The events/agent/ routers own the pi.on registration and call these methods.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { setFinishPhaseWhitelisted } from "../../git/worktrees/worktree-lifecycle.js";
|
|
19
|
+
import { log, NO_ERROR } from "../../log.js";
|
|
20
|
+
import { getSettings } from "../../settings/settings-ui.js";
|
|
21
|
+
import type { IAgentLifecycle, ICompaction, IPhaseReady } from "../../shared/workflow-types.js";
|
|
22
|
+
import type { FeatureSession } from "../../state/feature-session.js";
|
|
23
|
+
import { schedulePostTurnDrain } from "../../state/post-turn-dispatch.js";
|
|
24
|
+
import { isSubagentSession } from "../../state/state-persistence.js";
|
|
25
|
+
import type { AutoAgentCallback } from "./auto-agent-state-machine.js";
|
|
26
|
+
|
|
27
|
+
/** Find the last assistant message in a message list (reverse-search for role === "assistant"). Generic over the message type so it avoids cross-package SDK imports. */
|
|
28
|
+
function findLastAssistantMessage<TMessage extends { role: string; stopReason?: string; errorMessage?: string }>(
|
|
29
|
+
messages: readonly TMessage[],
|
|
30
|
+
): TMessage | undefined {
|
|
31
|
+
return [...messages].reverse().find((m) => m.role === "assistant");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Regex for retryable errors (transient network/API failures) */
|
|
35
|
+
const RETRYABLE_ERROR_RE =
|
|
36
|
+
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
|
|
37
|
+
|
|
38
|
+
export interface AgentLifecycleDeps {
|
|
39
|
+
pi: ExtensionAPI;
|
|
40
|
+
handler: FeatureSession;
|
|
41
|
+
compaction: ICompaction;
|
|
42
|
+
phaseReady: IPhaseReady;
|
|
43
|
+
getAutoAgentCallback: () => AutoAgentCallback | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Construct the agent-lifecycle domain object. The returned methods are the
|
|
48
|
+
* agent_start / agent_end handler bodies — the events/agent/ routers call them.
|
|
49
|
+
*/
|
|
50
|
+
export function createAgentLifecycle(deps: AgentLifecycleDeps): IAgentLifecycle {
|
|
51
|
+
const { pi, handler, compaction, phaseReady, getAutoAgentCallback } = deps;
|
|
52
|
+
|
|
53
|
+
async function onAgentStart(): Promise<void> {
|
|
54
|
+
compaction.setAgentFinished(false);
|
|
55
|
+
// Re-arm the finish-phase guardrail whitelist at the start of every turn while
|
|
56
|
+
// the finish phase is active (worktree policy + worktree path). The agent_end
|
|
57
|
+
// handler clears it at turn end (crash safety + clears when leaving finish), so
|
|
58
|
+
// re-arming here keeps it alive across the multi-turn interactive finish flow
|
|
59
|
+
// (turn 1 presents the option menu → turn 2 executes the merge). Sole arming site.
|
|
60
|
+
if (!isSubagentSession()) {
|
|
61
|
+
const ws = handler.getWorkflowState();
|
|
62
|
+
const fs = handler.getActiveFeatureState();
|
|
63
|
+
if (ws?.currentPhase === "finish" && getSettings().branchPolicy === "worktree" && fs?.git?.worktreePath) {
|
|
64
|
+
setFinishPhaseWhitelisted(true);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
log.info("[workflow] agent_start: resetting skill tracking");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function onAgentEnd(
|
|
71
|
+
event: { messages?: ReadonlyArray<{ role: string; stopReason?: string; errorMessage?: string }> },
|
|
72
|
+
_ctx: ExtensionContext,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
// Clear finish-phase guardrail whitelist flag on agent end
|
|
75
|
+
setFinishPhaseWhitelisted(false);
|
|
76
|
+
// Reset the once-per-agent-turn phase_ready guard. This is the per-cycle
|
|
77
|
+
// boundary for the guard: each low-level run (including retry / compact /
|
|
78
|
+
// followUp continuations) ends with its own agent_end, so the guard is cleared
|
|
79
|
+
// here between every cycle. NOT reset on turn_end: a pi "turn" is one LLM
|
|
80
|
+
// response, and a confused model's repeated phase_ready calls span multiple
|
|
81
|
+
// turns within one agent run (those repeats must stay collapsed until the run
|
|
82
|
+
// truly ends). The phase-transition followUp itself is drained at
|
|
83
|
+
// agent_settled (see onAgentSettled), not here.
|
|
84
|
+
phaseReady.resetTracking();
|
|
85
|
+
// Don't trigger finish-done detection if Pi will auto-retry — the agent hasn't truly finished.
|
|
86
|
+
// Pi emits agent_end before its own retry check, so we must detect this ourselves.
|
|
87
|
+
let lastError: string | undefined;
|
|
88
|
+
if (event.messages?.length) {
|
|
89
|
+
const lastAssistant = findLastAssistantMessage(event.messages);
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
lastAssistant &&
|
|
93
|
+
lastAssistant.stopReason === "error" &&
|
|
94
|
+
lastAssistant.errorMessage &&
|
|
95
|
+
RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)
|
|
96
|
+
) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (lastAssistant && lastAssistant.stopReason === "error" && lastAssistant.errorMessage) {
|
|
100
|
+
lastError = lastAssistant.errorMessage;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
compaction.setAgentFinished(true);
|
|
105
|
+
|
|
106
|
+
if (isSubagentSession()) return;
|
|
107
|
+
|
|
108
|
+
const latestState = handler.getWorkflowState();
|
|
109
|
+
log.info(
|
|
110
|
+
`[workflow] agent_end fired, activeFeatureSlug=${handler.getActiveFeatureSlug()}, currentPhase=${latestState?.currentPhase}`,
|
|
111
|
+
);
|
|
112
|
+
if (!latestState) return;
|
|
113
|
+
|
|
114
|
+
// Notify auto-agent of non-retryable errors during execution phases
|
|
115
|
+
if (lastError) {
|
|
116
|
+
const errorSlug = handler.getActiveFeatureSlug();
|
|
117
|
+
if (errorSlug && ["implement", "verify", "review"].includes(latestState.currentPhase ?? "")) {
|
|
118
|
+
try {
|
|
119
|
+
const autoAgentCb = getAutoAgentCallback();
|
|
120
|
+
if (autoAgentCb) {
|
|
121
|
+
log.info(`[workflow] auto-agent callback: notifying feature error for ${errorSlug}`);
|
|
122
|
+
autoAgentCb.onFeatureError(errorSlug, lastError);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log.error(`[workflow] auto-agent onFeatureError callback error: ${err}`, NO_ERROR);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function onAgentSettled(): Promise<void> {
|
|
132
|
+
// pi will not continue automatically — deliver any phase-transition followUp
|
|
133
|
+
// staged during the run (design/plan review iterations, phase handoffs). The
|
|
134
|
+
// drain is DEFERRED (see post-turn-dispatch.ts): a synchronous sendUserMessage
|
|
135
|
+
// here would re-enter _runAgentPrompt before the outer run's finally unwinds,
|
|
136
|
+
// and the delay lets a user message typed at settle time start its turn first
|
|
137
|
+
// (the staged followUp then enqueues behind it instead of racing it).
|
|
138
|
+
schedulePostTurnDrain(pi);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { onAgentStart, onAgentEnd, onAgentSettled };
|
|
142
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
import { log } from "../../log.js";
|
|
5
|
+
|
|
6
|
+
/** Options for GracePeriodManager. durationMs defaults to 30_000 when omitted. */
|
|
7
|
+
export type GracePeriodOpts = { durationMs?: number };
|
|
8
|
+
|
|
9
|
+
export class GracePeriodManager {
|
|
10
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
11
|
+
private remainingMs: number = 0;
|
|
12
|
+
private readonly durationMs: number;
|
|
13
|
+
private readonly tickMs = 1000;
|
|
14
|
+
private active = false;
|
|
15
|
+
private paused = false;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly onExpired: () => Promise<void>,
|
|
19
|
+
private readonly onTick: (remainingSeconds: number) => void,
|
|
20
|
+
private readonly onEnd: () => void,
|
|
21
|
+
opts: GracePeriodOpts,
|
|
22
|
+
) {
|
|
23
|
+
this.durationMs = opts.durationMs ?? 30_000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start(): void {
|
|
27
|
+
this.stop();
|
|
28
|
+
this.active = true;
|
|
29
|
+
this.remainingMs = this.durationMs;
|
|
30
|
+
|
|
31
|
+
this.timer = setInterval(() => {
|
|
32
|
+
if (this.paused) return;
|
|
33
|
+
this.remainingMs -= this.tickMs;
|
|
34
|
+
this.onTick(Math.max(0, Math.ceil(this.remainingMs / 1000)));
|
|
35
|
+
|
|
36
|
+
if (this.remainingMs <= 0) {
|
|
37
|
+
this.stop();
|
|
38
|
+
this.onExpired().catch((err) => {
|
|
39
|
+
log.error("[GracePeriodManager] onExpired error:", err);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}, this.tickMs);
|
|
43
|
+
|
|
44
|
+
this.onTick(Math.ceil(this.remainingMs / 1000));
|
|
45
|
+
log.info(`[grace-period] started (${this.durationMs}ms)`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
onUserActivity(): void {
|
|
49
|
+
if (!this.active) return;
|
|
50
|
+
this.remainingMs = this.durationMs;
|
|
51
|
+
if (!this.paused) {
|
|
52
|
+
this.onTick(Math.ceil(this.durationMs / 1000));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pause(): void {
|
|
57
|
+
this.paused = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resume(): void {
|
|
61
|
+
this.paused = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop(): void {
|
|
65
|
+
const wasActive = this.active;
|
|
66
|
+
if (this.timer) {
|
|
67
|
+
clearInterval(this.timer);
|
|
68
|
+
this.timer = null;
|
|
69
|
+
}
|
|
70
|
+
this.active = false;
|
|
71
|
+
this.paused = false;
|
|
72
|
+
// Only call onEnd if we were actually active — avoids spurious onEnd during start()'s cleanup
|
|
73
|
+
if (wasActive) this.onEnd();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
isActive(): boolean {
|
|
77
|
+
return this.active;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getRemainingSeconds(): number {
|
|
81
|
+
return Math.max(0, Math.ceil(this.remainingMs / 1000));
|
|
82
|
+
}
|
|
83
|
+
}
|