avtc-pi-featyard 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/agents/fy-design-reviewer.md +77 -0
  4. package/agents/fy-feature-verifier.md +24 -0
  5. package/agents/fy-general-reviewer.md +81 -0
  6. package/agents/fy-guidelines-reviewer.md +46 -0
  7. package/agents/fy-implementer.md +20 -0
  8. package/agents/fy-performance-reviewer.md +46 -0
  9. package/agents/fy-plan-reviewer.md +74 -0
  10. package/agents/fy-plan-verifier.md +44 -0
  11. package/agents/fy-quality-reviewer.md +50 -0
  12. package/agents/fy-requirements-reviewer.md +46 -0
  13. package/agents/fy-researcher.md +6 -0
  14. package/agents/fy-security-reviewer.md +46 -0
  15. package/agents/fy-task-verifier.md +42 -0
  16. package/agents/fy-testing-reviewer.md +60 -0
  17. package/assets/images/kanban-board.png +0 -0
  18. package/assets/images/settings-dropdown.png +0 -0
  19. package/assets/images/settings.png +0 -0
  20. package/assets/images/widget.png +0 -0
  21. package/docs/CONFIGURATION.md +266 -0
  22. package/index.ts +8 -0
  23. package/package.json +85 -0
  24. package/skills/fy-design/SKILL.md +108 -0
  25. package/skills/fy-design-review/SKILL.md +45 -0
  26. package/skills/fy-finish/SKILL.md +28 -0
  27. package/skills/fy-implement/SKILL.md +36 -0
  28. package/skills/fy-plan/SKILL.md +77 -0
  29. package/skills/fy-plan-review/SKILL.md +45 -0
  30. package/skills/fy-research/SKILL.md +38 -0
  31. package/skills/fy-review/SKILL.md +88 -0
  32. package/skills/fy-verify/SKILL.md +23 -0
  33. package/src/commands/auto-agent-commands.ts +748 -0
  34. package/src/commands/kanban-commands.ts +278 -0
  35. package/src/commands/workflow-commands.ts +531 -0
  36. package/src/compaction/compact-handler.ts +371 -0
  37. package/src/compaction/compact-message.ts +52 -0
  38. package/src/compaction/compact-trigger.ts +121 -0
  39. package/src/compaction/safe-editor-write.ts +18 -0
  40. package/src/events/agent/agent-end.ts +19 -0
  41. package/src/events/agent/agent-settled.ts +20 -0
  42. package/src/events/agent/agent-start.ts +18 -0
  43. package/src/events/agent/turn-end.ts +20 -0
  44. package/src/events/agent/turn-start.ts +19 -0
  45. package/src/events/event-deps.ts +33 -0
  46. package/src/events/index.ts +91 -0
  47. package/src/events/input/input.ts +143 -0
  48. package/src/events/session/context.ts +68 -0
  49. package/src/events/session/model-select.ts +20 -0
  50. package/src/events/session/session-compact.ts +20 -0
  51. package/src/events/session/session-shutdown.ts +26 -0
  52. package/src/events/session/session-start.ts +45 -0
  53. package/src/events/session/session-tree.ts +17 -0
  54. package/src/events/tool/tool-call.ts +58 -0
  55. package/src/events/tool/tool-result.ts +54 -0
  56. package/src/git/git-queries.ts +154 -0
  57. package/src/git/resolve-base-branch.ts +173 -0
  58. package/src/git/worktrees/worktree-helpers.ts +142 -0
  59. package/src/git/worktrees/worktree-interception.ts +66 -0
  60. package/src/git/worktrees/worktree-lifecycle.ts +380 -0
  61. package/src/guardrails/completion-gating.ts +63 -0
  62. package/src/guardrails/file-classifier.ts +461 -0
  63. package/src/guardrails/force-add-guard.ts +312 -0
  64. package/src/guardrails/guardrail-tracker.ts +73 -0
  65. package/src/guardrails/guardrail-violations.ts +47 -0
  66. package/src/guardrails/guardrails-engine.ts +663 -0
  67. package/src/guardrails/session-guardrails.ts +75 -0
  68. package/src/guardrails/shell-decompose.ts +285 -0
  69. package/src/guardrails/tdd-enforcement.ts +122 -0
  70. package/src/guardrails/test-output.ts +272 -0
  71. package/src/index.ts +598 -0
  72. package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
  73. package/src/integrations/subagent-integration.ts +166 -0
  74. package/src/integrations/todo-integration.ts +58 -0
  75. package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
  76. package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
  77. package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
  78. package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
  79. package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
  80. package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
  81. package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
  82. package/src/kanban/data/kanban-database.ts +851 -0
  83. package/src/kanban/data/kanban-db-schema.ts +137 -0
  84. package/src/kanban/data/kanban-detect-project.ts +43 -0
  85. package/src/kanban/data/kanban-move-feature.ts +43 -0
  86. package/src/kanban/data/kanban-types.ts +64 -0
  87. package/src/kanban/ensure-feature.ts +226 -0
  88. package/src/kanban/kanban-board-ui/app.js +1125 -0
  89. package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
  90. package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
  91. package/src/kanban/kanban-board-ui/index.html +127 -0
  92. package/src/kanban/kanban-board-ui/style.css +820 -0
  93. package/src/kanban/kanban-bridge.ts +216 -0
  94. package/src/kanban/kanban-context.ts +43 -0
  95. package/src/kanban/kanban-events.ts +34 -0
  96. package/src/kanban/kanban-generate-title.ts +259 -0
  97. package/src/kanban/kanban-generate-topic.ts +170 -0
  98. package/src/kanban/kanban-operations.ts +170 -0
  99. package/src/kanban/kanban-server.ts +931 -0
  100. package/src/kanban/kanban-turn-handlers.ts +55 -0
  101. package/src/kanban/model-capture.ts +34 -0
  102. package/src/log.ts +47 -0
  103. package/src/phases/env-sync.ts +64 -0
  104. package/src/phases/execution-mode.ts +79 -0
  105. package/src/phases/implement-to-verify.ts +31 -0
  106. package/src/phases/phase-progression.ts +249 -0
  107. package/src/phases/phase-transitions.ts +226 -0
  108. package/src/phases/workflow-router.ts +98 -0
  109. package/src/prompts/finish-instructions.ts +143 -0
  110. package/src/prompts/skill-block-builder.ts +73 -0
  111. package/src/prompts/skill-expansion.ts +262 -0
  112. package/src/prompts/task-gate-skill.ts +83 -0
  113. package/src/prompts/template-engine.ts +433 -0
  114. package/src/prompts/text-blocks.ts +279 -0
  115. package/src/review/review-context.ts +56 -0
  116. package/src/review/review-counter.ts +66 -0
  117. package/src/review/review-empty-loop-tracking.ts +52 -0
  118. package/src/review/review-loops.ts +217 -0
  119. package/src/review/review-report.ts +101 -0
  120. package/src/settings/model-overrides.ts +269 -0
  121. package/src/settings/settings-schema.ts +458 -0
  122. package/src/settings/settings-types.ts +81 -0
  123. package/src/settings/settings-ui.ts +222 -0
  124. package/src/shared/errors.ts +24 -0
  125. package/src/shared/global.d.ts +22 -0
  126. package/src/shared/session-entries.ts +24 -0
  127. package/src/shared/types.ts +195 -0
  128. package/src/shared/workflow-refs.ts +253 -0
  129. package/src/shared/workflow-types.ts +180 -0
  130. package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
  131. package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
  132. package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
  133. package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
  134. package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
  135. package/src/state/archive-artifacts.ts +602 -0
  136. package/src/state/artifact-junction.ts +462 -0
  137. package/src/state/artifact-paths.ts +230 -0
  138. package/src/state/feature-activation.ts +239 -0
  139. package/src/state/feature-management.ts +325 -0
  140. package/src/state/feature-record-store.ts +39 -0
  141. package/src/state/feature-session.ts +263 -0
  142. package/src/state/feature-state.ts +439 -0
  143. package/src/state/post-turn-dispatch.ts +106 -0
  144. package/src/state/session-lifecycle.ts +346 -0
  145. package/src/state/state-persistence.ts +122 -0
  146. package/src/state/worth-notes.ts +89 -0
  147. package/src/tools/add-to-backlog.ts +117 -0
  148. package/src/tools/phase-ready.ts +742 -0
  149. package/src/tools/task-ready-advance.ts +235 -0
  150. package/src/tools/text-result.ts +15 -0
  151. package/src/ui/featyard-widget.ts +250 -0
  152. package/src/ui/manage-features-dialog.ts +246 -0
  153. package/src/ui/select-dialog.ts +27 -0
@@ -0,0 +1,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
+ }