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,380 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
import { execSync as _realExecSync } from "node:child_process";
|
|
5
|
+
import { rmSync as _realRmSync } from "node:fs";
|
|
6
|
+
import { resolve as resolvePath } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { log } from "../../log.js";
|
|
9
|
+
import type { FeatureState } from "../../state/feature-state.js";
|
|
10
|
+
import { NO_STATUS } from "../../ui/featyard-widget.js";
|
|
11
|
+
|
|
12
|
+
/** Injectable execSync — tests can override via _setExecSync */
|
|
13
|
+
let _execSync: typeof _realExecSync = _realExecSync;
|
|
14
|
+
|
|
15
|
+
/** @internal Test hook to override execSync */
|
|
16
|
+
export function _setExecSync(fn: typeof _realExecSync): void {
|
|
17
|
+
_execSync = fn;
|
|
18
|
+
_cachedMainRepoPath = undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Injectable recursive directory delete — used as a long-path fallback when
|
|
22
|
+
* `git worktree remove` fails on Windows (node_modules paths > MAX_PATH).
|
|
23
|
+
* Tests can override via _setRmSync; pass null to restore the default. */
|
|
24
|
+
let _rmSync: typeof _realRmSync = _realRmSync;
|
|
25
|
+
|
|
26
|
+
/** @internal Test hook to override the recursive-delete fallback (null restores default). */
|
|
27
|
+
export function _setRmSync(fn: typeof _realRmSync | null): void {
|
|
28
|
+
_rmSync = fn ?? _realRmSync;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Normalize Windows backslashes to forward slashes for consistent git path handling. */
|
|
32
|
+
function normalizeGitPath(p: string): string {
|
|
33
|
+
return p.replace(/\\/g, "/");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Finish phase guardrail whitelist helpers ---
|
|
37
|
+
// Shared between workflow-monitor.ts and guardrail integration layer
|
|
38
|
+
// Writes to the __piWorkflowMonitor bridge object (bridge migration)
|
|
39
|
+
|
|
40
|
+
export function setFinishPhaseWhitelisted(value: boolean): void {
|
|
41
|
+
if (globalThis.__piWorkflowMonitor) {
|
|
42
|
+
globalThis.__piWorkflowMonitor.finishPhaseWhitelisted = value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isFinishPhaseWhitelisted(): boolean {
|
|
47
|
+
return globalThis.__piWorkflowMonitor?.finishPhaseWhitelisted === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ExecFn = (command: string, options?: { cwd?: string }) => Promise<{ exitCode: number; stdout: string }>;
|
|
51
|
+
|
|
52
|
+
/** Characters that are safe for shell interpolation — alphanumeric, dash, underscore, dot, forward slash, at */
|
|
53
|
+
const SAFE_SHELL_RE = /^[a-zA-Z0-9._@/-]+$/;
|
|
54
|
+
|
|
55
|
+
function validateShellArg(value: string, name: string): void {
|
|
56
|
+
if (!SAFE_SHELL_RE.test(value)) {
|
|
57
|
+
throw new Error(`Invalid ${name}: contains shell-unsafe characters`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CreateWorktreeOptions {
|
|
62
|
+
slug: string;
|
|
63
|
+
baseBranch: string;
|
|
64
|
+
worktreeDir?: string;
|
|
65
|
+
exec: ExecFn;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RemoveWorktreeOptions {
|
|
69
|
+
worktreePath: string;
|
|
70
|
+
mainRepoPath: string;
|
|
71
|
+
branchName?: string;
|
|
72
|
+
baseBranch?: string; // Optional: for merge-status check before branch deletion
|
|
73
|
+
exec: ExecFn;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function createWorktree(opts: CreateWorktreeOptions): Promise<{ path: string; branch: string }> {
|
|
77
|
+
validateShellArg(opts.slug, "slug");
|
|
78
|
+
validateShellArg(opts.baseBranch, "baseBranch");
|
|
79
|
+
if (opts.worktreeDir) {
|
|
80
|
+
validateShellArg(opts.worktreeDir, "worktreeDir");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const branch = `feature/${opts.slug}`;
|
|
84
|
+
const worktreeDir = opts.worktreeDir ?? `.worktrees/${opts.slug}`;
|
|
85
|
+
|
|
86
|
+
log.info(`[worktree] Creating worktree: ${worktreeDir} on branch ${branch} from ${opts.baseBranch}`);
|
|
87
|
+
|
|
88
|
+
const result = await opts.exec(`git worktree add ${worktreeDir} -b ${branch} ${opts.baseBranch}`);
|
|
89
|
+
|
|
90
|
+
if (result.exitCode !== 0) {
|
|
91
|
+
throw new Error(`Failed to create worktree: ${result.stdout}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { path: worktreeDir, branch };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function removeWorktree(opts: RemoveWorktreeOptions): Promise<void> {
|
|
98
|
+
validateShellArg(opts.worktreePath, "worktreePath");
|
|
99
|
+
// NOTE: mainRepoPath is intentionally NOT validated — it is passed only as an exec
|
|
100
|
+
// {cwd} option (never interpolated into the shell string), so a drive-letter colon
|
|
101
|
+
// (e.g. "E:/repo" on Windows) must not be rejected. Validating it broke all worktree
|
|
102
|
+
// + branch cleanup on Windows. The interpolated args below remain validated.
|
|
103
|
+
if (opts.branchName) {
|
|
104
|
+
validateShellArg(opts.branchName, "branchName");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
log.info(`[worktree] Removing worktree: ${opts.worktreePath}`);
|
|
108
|
+
|
|
109
|
+
// Execute from main repo (not worktree). --force discards untracked/modified files
|
|
110
|
+
// (build artifacts like node_modules / pnpm files common in a completed feature's
|
|
111
|
+
// worktree) so a done+merged feature cleans up even when the agent left artifacts.
|
|
112
|
+
const result = await opts.exec(`git worktree remove --force ${opts.worktreePath}`, { cwd: opts.mainRepoPath });
|
|
113
|
+
|
|
114
|
+
if (result.exitCode !== 0) {
|
|
115
|
+
// git worktree remove can exit non-zero on Windows when nested paths exceed
|
|
116
|
+
// MAX_PATH (e.g. node_modules/.pnpm/<deep>). git may have de-registered the
|
|
117
|
+
// worktree but failed to delete the directory, OR refused entirely. Fall back to
|
|
118
|
+
// a long-path-capable recursive delete of the absolute path, then prune any stale
|
|
119
|
+
// worktree administrative files. Best-effort — finish continues regardless.
|
|
120
|
+
log.warn(`[worktree] git worktree remove failed, falling back to direct delete: ${result.stdout}`);
|
|
121
|
+
const absPath = resolvePath(opts.mainRepoPath, opts.worktreePath);
|
|
122
|
+
try {
|
|
123
|
+
_rmSync(absPath, { recursive: true, force: true });
|
|
124
|
+
} catch (err: unknown) {
|
|
125
|
+
log.warn(`[worktree] direct delete failed for ${absPath}: ${err}`);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await opts.exec("git worktree prune", { cwd: opts.mainRepoPath });
|
|
129
|
+
} catch (err: unknown) {
|
|
130
|
+
log.warn(`[worktree] git worktree prune failed: ${err}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Optionally delete the branch too
|
|
135
|
+
if (opts.branchName) {
|
|
136
|
+
if (opts.baseBranch) {
|
|
137
|
+
validateShellArg(opts.baseBranch, "baseBranch");
|
|
138
|
+
// Check if branch is fully merged into baseBranch before deleting
|
|
139
|
+
try {
|
|
140
|
+
const mergedResult = await opts.exec(`git branch --merged ${opts.baseBranch}`, { cwd: opts.mainRepoPath });
|
|
141
|
+
const mergedBranches = mergedResult.stdout
|
|
142
|
+
.split("\n")
|
|
143
|
+
.map((b) => b.trim().replace(/^[*+]\s*/, "")) // strip *, + prefixes from git branch output
|
|
144
|
+
.filter(Boolean);
|
|
145
|
+
if (!mergedBranches.includes(opts.branchName)) {
|
|
146
|
+
log.warn(
|
|
147
|
+
`[worktree] Branch ${opts.branchName} not fully merged into ${opts.baseBranch} — skipping deletion.`,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
log.warn(`[worktree] git branch --merged failed (${e}) — skipping branch deletion for safety.`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
await opts.exec(`git branch -d ${opts.branchName}`, { cwd: opts.mainRepoPath });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve main repo path from a worktree context.
|
|
162
|
+
*
|
|
163
|
+
* For worktrees: uses `--git-common-dir` which always points to the shared
|
|
164
|
+
* .git directory (e.g. /path/to/main-repo/.git), then strips the.git suffix.
|
|
165
|
+
*
|
|
166
|
+
* For non-worktree repos: falls back to `--show-toplevel` which returns the
|
|
167
|
+
* repo root directly.
|
|
168
|
+
*/
|
|
169
|
+
export async function resolveMainRepoPath(exec: ExecFn): Promise<string> {
|
|
170
|
+
// First, try --git-common-dir to detect worktrees
|
|
171
|
+
const gitDir = await exec("git rev-parse --git-common-dir");
|
|
172
|
+
const normalized = normalizeGitPath(gitDir.stdout.trim());
|
|
173
|
+
|
|
174
|
+
// If the path is absolute (starts with / or a drive letter like C), it's
|
|
175
|
+
// either a worktree or an absolute common-dir path. Strip /.git to get the repo root.
|
|
176
|
+
if (/^\//.test(normalized) || /^[a-zA-Z]:/.test(normalized)) {
|
|
177
|
+
return normalized.replace(/\/\.git(?:\/worktrees\/[^/]+)?$/, "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// For relative paths (e.g. ".git" in non-worktree repos), fall back to
|
|
181
|
+
// --show-toplevel which always returns an absolute path.
|
|
182
|
+
const topLevel = await exec("git rev-parse --show-toplevel");
|
|
183
|
+
return normalizeGitPath(topLevel.stdout.trim());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Synchronous version of resolveMainRepoPath for use in sync contexts (e.g. substituteTemplates).
|
|
188
|
+
* Uses the same strategy as the async version: --git-common-dir first, --show-toplevel fallback.
|
|
189
|
+
*/
|
|
190
|
+
let _cachedMainRepoPath: string | null | undefined;
|
|
191
|
+
|
|
192
|
+
export function resolveMainRepoPathSync(): string | null {
|
|
193
|
+
if (_cachedMainRepoPath !== undefined) return _cachedMainRepoPath;
|
|
194
|
+
try {
|
|
195
|
+
const gitDir = normalizeGitPath(
|
|
196
|
+
_execSync("git rev-parse --git-common-dir", { encoding: "utf-8", stdio: ["pipe"] }).trim(),
|
|
197
|
+
);
|
|
198
|
+
// If the path is absolute, strip /.git (and optional /worktrees/<id>) to get the repo root
|
|
199
|
+
if (/^\//.test(gitDir) || /^[a-zA-Z]:/.test(gitDir)) {
|
|
200
|
+
_cachedMainRepoPath = gitDir.replace(/\/\.git(?:\/worktrees\/[^/]+)?$/, "");
|
|
201
|
+
return _cachedMainRepoPath;
|
|
202
|
+
}
|
|
203
|
+
// For relative paths, fall back to --show-toplevel
|
|
204
|
+
_cachedMainRepoPath = normalizeGitPath(
|
|
205
|
+
_execSync("git rev-parse --show-toplevel", { encoding: "utf-8", stdio: ["pipe"] }).trim(),
|
|
206
|
+
);
|
|
207
|
+
return _cachedMainRepoPath;
|
|
208
|
+
} catch {
|
|
209
|
+
_cachedMainRepoPath = null;
|
|
210
|
+
return _cachedMainRepoPath;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Detect the base (default) branch for the repository.
|
|
216
|
+
*
|
|
217
|
+
* Strategy:
|
|
218
|
+
* 1. Try `git symbolic-ref refs/remotes/origin/HEAD` (requires remote)
|
|
219
|
+
* 2. If only one remote branch exists, use it
|
|
220
|
+
* 3. Check for common branch names locally (main, master, develop)
|
|
221
|
+
* 4. Return null if ambiguous (multiple candidates) — caller should prompt
|
|
222
|
+
*/
|
|
223
|
+
export async function detectBaseBranch(exec: ExecFn): Promise<string | null> {
|
|
224
|
+
// Strategy 1: remote HEAD
|
|
225
|
+
try {
|
|
226
|
+
const result = await exec("git symbolic-ref refs/remotes/origin/HEAD");
|
|
227
|
+
if (result.exitCode === 0) {
|
|
228
|
+
const ref = result.stdout.trim();
|
|
229
|
+
const match = ref.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
230
|
+
if (match) return match[1];
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// No remote or no HEAD
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Strategy 2: check remote branches
|
|
237
|
+
try {
|
|
238
|
+
const result = await exec("git branch -r");
|
|
239
|
+
if (result.exitCode === 0) {
|
|
240
|
+
const branches = result.stdout
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((l) => l.trim())
|
|
243
|
+
.filter((l) => l && !l.includes("->")) // skip HEAD symlinks
|
|
244
|
+
.map((l) => l.replace(/^origin\//, ""));
|
|
245
|
+
if (branches.length === 1) {
|
|
246
|
+
return branches[0] ?? null;
|
|
247
|
+
}
|
|
248
|
+
if (branches.length > 1) {
|
|
249
|
+
// Multiple remote branches — ambiguous
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// No remote
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Strategy 3: check common branch names locally
|
|
258
|
+
const candidates = ["main", "master", "develop"];
|
|
259
|
+
const found: string[] = [];
|
|
260
|
+
for (const name of candidates) {
|
|
261
|
+
try {
|
|
262
|
+
const result = await exec(`git branch --list ${name}`);
|
|
263
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
264
|
+
found.push(name);
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// ignore
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (found.length === 1) return found[0] ?? null;
|
|
272
|
+
if (found.length > 1) return null; // ambiguous
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get all candidate base branches for the repository.
|
|
279
|
+
* Returns an array of branch names that could be the base branch.
|
|
280
|
+
* Used when detectBaseBranch returns null to prompt user selection.
|
|
281
|
+
*/
|
|
282
|
+
export async function getBaseBranchCandidates(exec: ExecFn): Promise<string[]> {
|
|
283
|
+
const candidates = new Set<string>();
|
|
284
|
+
|
|
285
|
+
// Add current branch (user is likely on a feature branch forked from base)
|
|
286
|
+
try {
|
|
287
|
+
const result = await exec("git rev-parse --abbrev-ref HEAD");
|
|
288
|
+
if (result.exitCode === 0) {
|
|
289
|
+
const branch = result.stdout.trim();
|
|
290
|
+
if (branch && branch !== "HEAD") {
|
|
291
|
+
candidates.add(branch);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check common branch names — local and remote
|
|
299
|
+
// We only check known base branch names, not all remote branches,
|
|
300
|
+
// to avoid flooding the selection list with feature branches.
|
|
301
|
+
const common = ["main", "master", "develop"];
|
|
302
|
+
for (const name of common) {
|
|
303
|
+
try {
|
|
304
|
+
// Check local first
|
|
305
|
+
const local = await exec(`git branch --list ${name}`);
|
|
306
|
+
if (local.exitCode === 0 && local.stdout.trim()) {
|
|
307
|
+
candidates.add(name);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// Fall back to remote
|
|
311
|
+
const remote = await exec(`git branch -r --list origin/${name}`);
|
|
312
|
+
if (remote.exitCode === 0 && remote.stdout.trim()) {
|
|
313
|
+
candidates.add(name);
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// ignore
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// If no candidates found, try to verify common defaults exist
|
|
321
|
+
if (candidates.size === 0) {
|
|
322
|
+
const verified: string[] = [];
|
|
323
|
+
for (const name of ["main", "master"]) {
|
|
324
|
+
try {
|
|
325
|
+
const result = await exec(`git rev-parse --verify refs/heads/${name}`);
|
|
326
|
+
if (result.exitCode === 0) {
|
|
327
|
+
verified.push(name);
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// ignore
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (verified.length > 0) {
|
|
334
|
+
return verified;
|
|
335
|
+
}
|
|
336
|
+
// Absolute last resort — no branches could be detected at all
|
|
337
|
+
log.warn("[worktree] No base branch candidates found; returning ['main'] as fallback");
|
|
338
|
+
return ["main"];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return Array.from(candidates).sort();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Clean up worktree on feature finish. Best-effort — does not block on failure. */
|
|
345
|
+
export async function cleanupWorktreeOnFinish(
|
|
346
|
+
featureState: Pick<FeatureState, "git" | "featureSlug">,
|
|
347
|
+
gitExec: ExecFn,
|
|
348
|
+
settings: { baseBranch?: string | null },
|
|
349
|
+
worktreeStatusKey: string,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
if (!featureState.git.worktreePath) return;
|
|
352
|
+
|
|
353
|
+
const baseBranch = featureState.git.baseBranch ?? settings.baseBranch ?? undefined;
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const mainRepoPath = await resolveMainRepoPath(gitExec);
|
|
357
|
+
await removeWorktree({
|
|
358
|
+
worktreePath: featureState.git.worktreePath,
|
|
359
|
+
mainRepoPath,
|
|
360
|
+
branchName: `feature/${featureState.featureSlug}`,
|
|
361
|
+
baseBranch,
|
|
362
|
+
exec: gitExec,
|
|
363
|
+
});
|
|
364
|
+
log.info(`[workflow] Cleaned up worktree for ${featureState.featureSlug}`);
|
|
365
|
+
} catch (err: unknown) {
|
|
366
|
+
if (err instanceof TypeError || err instanceof ReferenceError || err instanceof SyntaxError) {
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
log.warn(`[workflow] Failed to clean up worktree for ${featureState.featureSlug}: ${err}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Always clear the worktree status icon once finish-cleanup has been attempted.
|
|
373
|
+
// Completion is the off-signal: a failed removal (caught above) must not leave the
|
|
374
|
+
// indicator stuck. (Programmer errors re-throw above, so this only runs on success
|
|
375
|
+
// or a swallowed non-fatal removal error.)
|
|
376
|
+
const guard = globalThis.__piCtx;
|
|
377
|
+
if (guard?.hasUI && guard?.ui?.setStatus) {
|
|
378
|
+
guard.ui.setStatus(worktreeStatusKey, NO_STATUS);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Publish gate for `git push` / `gh pr create`.
|
|
6
|
+
*
|
|
7
|
+
* Rule: a publish is blocked until the workflow reaches the **finish** phase. In the finish
|
|
8
|
+
* phase it is confirmed via a dialog (Allow / Block, default Block). No active feature → no
|
|
9
|
+
* gate (free push). Commits are NOT gated here — pre-commit discipline (checkPreCommitGate)
|
|
10
|
+
* owns commits.
|
|
11
|
+
*
|
|
12
|
+
* The finish-phase dialog is routed via avtc-pi-ui-components' showSelectWithNote, which
|
|
13
|
+
* forwards to the root session for subagent children (json + rpc), triggers notification
|
|
14
|
+
* attention when pi-notification is installed, and auto-resolves to the default (Block) on
|
|
15
|
+
* timeout or when no response is possible. So the confirm behaves identically whether the
|
|
16
|
+
* session is interactive, a subagent, or headless.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type SelectWithNoteOption, showSelectWithNote } from "avtc-pi-ui-components";
|
|
20
|
+
|
|
21
|
+
// Anchored to start of subcommand to avoid false positives from git-like strings inside
|
|
22
|
+
// arguments or string literals (e.g. node -e "...git push...").
|
|
23
|
+
const PUSH_RE = /^\s*git\s+push\b/;
|
|
24
|
+
const PR_RE = /^\s*gh\s+pr\s+create\b/;
|
|
25
|
+
|
|
26
|
+
export interface PublishGateDeps {
|
|
27
|
+
/** The session ctx (hasUI + ui.custom + mode) — forwarded to the dialog for bridge routing. Null when no ctx is available. */
|
|
28
|
+
ctx: { hasUI: boolean; ui?: unknown; mode: string } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Reason returned when a publish is blocked because the workflow is not yet at finish. */
|
|
32
|
+
export const PUBLISH_BEFORE_FINISH_REASON =
|
|
33
|
+
"git push / gh pr create are only allowed in the finish phase. Complete the workflow phases first.";
|
|
34
|
+
|
|
35
|
+
/** True if any subcommand is a `git push` or `gh pr create`. */
|
|
36
|
+
export function isPublishAction(subcommands: string[]): boolean {
|
|
37
|
+
return subcommands.some((sub) => PUSH_RE.test(sub) || PR_RE.test(sub));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* In the finish phase, ask the user (or, for a subagent, the root session via the ui-bridge)
|
|
42
|
+
* to confirm the publish. Default Block (fail-closed): an unattended / timed-out / no-bridge
|
|
43
|
+
* session never publishes. Returns "allowed" only on an explicit Allow.
|
|
44
|
+
*/
|
|
45
|
+
export async function promptPublishGate(deps: PublishGateDeps): Promise<"allowed" | "blocked"> {
|
|
46
|
+
// No ctx (e.g. fully headless with no bridge): fail-closed.
|
|
47
|
+
if (!deps.ctx) return "blocked";
|
|
48
|
+
|
|
49
|
+
const options: SelectWithNoteOption[] = [
|
|
50
|
+
{ label: "Allow once", value: "allow" },
|
|
51
|
+
{ label: "Block once", value: "block" },
|
|
52
|
+
];
|
|
53
|
+
const result = await showSelectWithNote(
|
|
54
|
+
deps.ctx as Parameters<typeof showSelectWithNote>[0],
|
|
55
|
+
"Confirm publish (git push / gh pr create)?",
|
|
56
|
+
options,
|
|
57
|
+
options[1], // Block — the safe no-response outcome
|
|
58
|
+
"featyard",
|
|
59
|
+
undefined, // wait indefinitely for the human (no timeout)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return result?.value === "allow" ? "allowed" : "blocked";
|
|
63
|
+
}
|