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,154 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Read-only git introspection for a coding-agent workflow: where is HEAD, is
6
+ * anything uncommitted, and what's staged. Every query degrades to a safe value
7
+ * (null / false / []) on failure and never throws — callers pay no try/catch;
8
+ * each failure surfaces as exactly one diagnostic warn in the log.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { log } from "../log.js";
12
+
13
+ /** Synchronous git invocation contract: returns captured stdout (caller trims); throws on non-zero exit, missing git binary, or any spawn error. */
14
+ export type GitRunner = (args: string[], cwd: string) => string;
15
+
16
+ /** Default runner: invokes the real `git` binary synchronously via child_process (no shell; args passed directly to avoid injection/quoting). */
17
+ export const defaultGitRunner: GitRunner = (args, cwd) => {
18
+ return execFileSync("git", args, {
19
+ cwd,
20
+ encoding: "utf8",
21
+ // stdin ignored so git can't block waiting for input; stdout/stderr piped (captured, not leaked to console).
22
+ stdio: ["ignore", "pipe", "pipe"],
23
+ });
24
+ };
25
+
26
+ // --- testability seam -------------------------------------------------------
27
+ // All four queries below route through this module-level slot. A test swaps it once
28
+ // with setGitRunner(stub) to stub ALL git invocation without touching the filesystem.
29
+ let gitRunner: GitRunner = defaultGitRunner;
30
+
31
+ /** Test seam — replace how this module shells out to git. Every query routes through the slot, so one call stubs them all. Restore the real runner with `setGitRunner(defaultGitRunner)`. */
32
+ export function setGitRunner(runner: GitRunner): void {
33
+ gitRunner = runner;
34
+ }
35
+
36
+ /** Convenience pass-through for `process.cwd()` so one-off callers don't have to supply a directory. */
37
+ export const PROCESS_CWD: string = process.cwd();
38
+
39
+ /** Sentinel fallbacks for queries that can't determine a value (avoids bare literals at call sites). */
40
+ const NO_REF: string | null = null;
41
+ const NO_SHA: string | null = null;
42
+ const CLEAN_TREE = false;
43
+
44
+ // --- internal failure path --------------------------------------------------
45
+ // One centralized failure path: run `compute` against the current runner; if it throws
46
+ // for ANY reason (not a repo, git missing, non-zero exit), emit exactly ONE warn
47
+ // (its text always contains "git") and return the query's safe fallback.
48
+ function query<T>(label: string, cwd: string, compute: (run: GitRunner) => T, fallback: T): T {
49
+ const run = gitRunner;
50
+ try {
51
+ return compute(run);
52
+ } catch (err) {
53
+ log.warn(`git ${label} failed in ${cwd}: ${err instanceof Error ? err.message : String(err)}`);
54
+ return fallback;
55
+ }
56
+ }
57
+
58
+ // --- queries ----------------------------------------------------------------
59
+
60
+ /** Symbolic identity at HEAD: the branch name when on a branch, else the short commit SHA. Null when it cannot be determined (not a repo, git unavailable). */
61
+ export function getBranchOrShortSha(cwd: string): string | null {
62
+ return query(
63
+ "identity",
64
+ cwd,
65
+ (run) => {
66
+ // `symbolic-ref --short HEAD` prints the branch name even on an unborn branch (no
67
+ // commits yet); it errors only when HEAD is detached, so treat that as "no branch".
68
+ try {
69
+ const branch = run(["symbolic-ref", "--short", "HEAD"], cwd).trim();
70
+ if (branch !== "") return branch;
71
+ } catch {
72
+ // detached HEAD (or symbolic-ref unavailable) — fall through to the short SHA.
73
+ }
74
+ const short = run(["rev-parse", "--short", "HEAD"], cwd).trim();
75
+ return short !== "" ? short : NO_REF;
76
+ },
77
+ NO_REF,
78
+ );
79
+ }
80
+
81
+ /** Full 40-character commit SHA of HEAD. Null when git is unavailable or returns anything that isn't exactly 40 lowercase hex chars. */
82
+ export function getHeadSha(cwd: string): string | null {
83
+ return query(
84
+ "head-sha",
85
+ cwd,
86
+ (run) => {
87
+ const sha = run(["rev-parse", "HEAD"], cwd).trim();
88
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : NO_SHA;
89
+ },
90
+ NO_SHA,
91
+ );
92
+ }
93
+
94
+ /** True if the working tree has ANY uncommitted change — staged, unstaged, or untracked. False when clean or on failure. */
95
+ export function isDirty(cwd: string): boolean {
96
+ return query(
97
+ "dirty",
98
+ cwd,
99
+ (run) => {
100
+ // `status --porcelain` is empty iff clean; it lists staged, unstaged, AND untracked ("??") files.
101
+ return run(["status", "--porcelain"], cwd).trim() !== "";
102
+ },
103
+ CLEAN_TREE,
104
+ );
105
+ }
106
+
107
+ /** File paths currently staged for commit (one per line from `git diff --cached --name-only`). Empty array when none or on failure. */
108
+ export function getStagedFiles(cwd: string): string[] {
109
+ return query(
110
+ "staged-files",
111
+ cwd,
112
+ (run) => {
113
+ const out = run(["diff", "--cached", "--name-only"], cwd).trim();
114
+ if (out === "") return [];
115
+ return out
116
+ .split("\n")
117
+ .map((line) => line.trim())
118
+ .filter((line) => line !== "");
119
+ },
120
+ [],
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Repo-relative paths of every working-tree change vs HEAD: staged, unstaged,
126
+ * AND untracked. This is the union `git status` reports — the full "current
127
+ * change set" — and is used to answer "did a test get written/modified in this
128
+ * change set?" without caring HOW a file was written (write/edit tool, heredoc,
129
+ * cp, sed, …). Returns an empty array when the tree is clean or on failure
130
+ * (not a repo, git unavailable).
131
+ *
132
+ * Uses `-z` for null-separated, never-quoted paths: avoids whitespace/special-
133
+ * char splitting and the `PATH -> NEWPATH` rename-arrow ambiguity of the
134
+ * default format. Each entry carries a 2-char `XY` status prefix followed by a
135
+ * space; the prefix is stripped before returning the bare path.
136
+ */
137
+ export function getWorkingTreeFiles(cwd: string): string[] {
138
+ return query(
139
+ "working-tree-files",
140
+ cwd,
141
+ (run) => {
142
+ // `-uall` lists individual files inside untracked directories (default
143
+ // `-unormal` collapses a wholly-untracked dir to a single `dir/` entry,
144
+ // hiding the test file inside). `-z` for null-separated, never-quoted paths.
145
+ const out = run(["status", "--porcelain", "-z", "--untracked-files=all"], cwd);
146
+ if (out === "") return [];
147
+ return out
148
+ .split("\0")
149
+ .map((entry) => entry.replace(/^.{3}/, "")) // strip the "XY " status prefix
150
+ .filter((p) => p !== "");
151
+ },
152
+ [],
153
+ );
154
+ }
@@ -0,0 +1,173 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Base branch resolution — extracts UI interaction, settings persistence,
6
+ * and git operations from the workflow-monitor factory for SRP.
7
+ */
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import * as autoAgentNotify from "../kanban/auto-agent/auto-agent-notify.js";
11
+ import { log } from "../log.js";
12
+ import { getSettings, updateSetting } from "../settings/settings-ui.js";
13
+ import { UserCancelledError, ValidationError } from "../shared/errors.js";
14
+ import { getActiveFeatureSlug } from "../shared/workflow-refs.js";
15
+ import { withCoordinator } from "../snippets/vendored/subscribe-to-dialog-coordinator.js";
16
+ import { getLastMessage, withAttention } from "../snippets/vendored/subscribe-to-notifications.js";
17
+ import { isSubagentSession } from "../state/state-persistence.js";
18
+ import { createGitExec } from "./worktrees/worktree-helpers.js";
19
+ import { detectBaseBranch, getBaseBranchCandidates } from "./worktrees/worktree-lifecycle.js";
20
+
21
+ /**
22
+ * Resolve the base branch for the current project.
23
+ *
24
+ * Priority: saved setting > user selection (with git detection) > auto-detection > "main"
25
+ */
26
+ export async function resolveBaseBranch(ctx: ExtensionContext): Promise<string> {
27
+ const settings = getSettings();
28
+ if (settings.baseBranch) return settings.baseBranch;
29
+
30
+ const gitExec = createGitExec(ctx);
31
+
32
+ try {
33
+ const detected = (await detectBaseBranch(gitExec)) ?? undefined;
34
+ const candidates = await getBaseBranchCandidates(gitExec);
35
+
36
+ // Always prompt user to confirm base branch (when not set in settings)
37
+ if (candidates.length > 0 && ctx.hasUI && !isSubagentSession()) {
38
+ return await promptBaseBranch(gitExec, detected, candidates);
39
+ } else if (detected) {
40
+ log.info(`[workflow] Auto-detected base branch: ${detected}`);
41
+ return detected;
42
+ } else {
43
+ log.warn(
44
+ `[workflow] Could not auto-detect base branch and no UI for prompt, defaulting to 'main'. Set baseBranch in settings to override.`,
45
+ );
46
+ return "main";
47
+ }
48
+ } catch (err) {
49
+ if (err instanceof UserCancelledError) throw err; // propagate cancel to caller
50
+ if (err instanceof ValidationError) throw err; // propagate validation errors
51
+ log.warn(`[workflow] Base branch detection failed, defaulting to 'main'. Set baseBranch in settings to override.`);
52
+ return "main";
53
+ }
54
+ }
55
+
56
+ /** Prompt user to select or enter a base branch, then persist the choice. */
57
+ async function promptBaseBranch(
58
+ gitExec: ReturnType<typeof createGitExec>,
59
+ detected: string | undefined,
60
+ candidates: string[],
61
+ ): Promise<string> {
62
+ const guard = globalThis.__piCtx;
63
+ const ui = guard?.ui;
64
+ if (!ui?.select) {
65
+ log.warn("[workflow] No UI available for base branch selection");
66
+ throw new UserCancelledError("Base branch selection requires interactive mode");
67
+ }
68
+ const CUSTOM_BRANCH_OPTION = "Type your own...";
69
+ const labels = [...candidates.map((b) => (b === detected ? `${b} (detected)` : b)), CUSTOM_BRANCH_OPTION];
70
+
71
+ const activeSlug = getActiveFeatureSlug();
72
+ if (activeSlug) autoAgentNotify.notifyAutoAgentBlocked(activeSlug);
73
+ const pickedLabel = await withAttention(
74
+ "workflow",
75
+ ["base branch selection", activeSlug, getLastMessage()].filter(Boolean).join(" • "),
76
+ () => withCoordinator(() => ui.select("Select the base branch for this project:", labels)),
77
+ );
78
+ if (activeSlug) autoAgentNotify.notifyAutoAgentUnblocked(activeSlug);
79
+
80
+ // User cancelled the dialog
81
+ if (pickedLabel === undefined) {
82
+ log.info("[workflow] User cancelled base branch selection");
83
+ throw new UserCancelledError("Base branch selection cancelled");
84
+ }
85
+
86
+ const baseBranch = await resolveBranchName(gitExec, pickedLabel, detected, CUSTOM_BRANCH_OPTION);
87
+ await persistBranchChoice(baseBranch);
88
+ return baseBranch;
89
+ }
90
+
91
+ /** Resolve the actual branch name from user selection (picked or custom-typed). */
92
+ async function resolveBranchName(
93
+ gitExec: ReturnType<typeof createGitExec>,
94
+ pickedLabel: string,
95
+ detected: string | undefined,
96
+ customOption: string,
97
+ ): Promise<string> {
98
+ if (pickedLabel !== customOption) {
99
+ return pickedLabel.replace(/ \(detected\)$/, "");
100
+ }
101
+
102
+ // Free-text input for custom branch name
103
+ const guard = globalThis.__piCtx;
104
+ const ui = guard?.ui;
105
+ if (!ui?.input) {
106
+ log.warn("[workflow] No UI available for custom branch input");
107
+ throw new UserCancelledError("Custom branch input requires interactive mode");
108
+ }
109
+ const customBranch = await withAttention(
110
+ "workflow",
111
+ ["custom branch", getLastMessage()].filter(Boolean).join(" • "),
112
+ () => withCoordinator(() => ui.input("Enter base branch name:", detected ?? "main")),
113
+ );
114
+ if (!customBranch?.trim()) {
115
+ log.info("[workflow] User cancelled custom branch input");
116
+ throw new UserCancelledError("Custom branch input cancelled");
117
+ }
118
+
119
+ const branch = customBranch.trim();
120
+ // Validate branch name — interpolated into shell commands via gitExec
121
+ if (!/^[a-zA-Z0-9._@/-]+$/.test(branch)) {
122
+ throw new ValidationError("Invalid branch name: contains shell-unsafe characters");
123
+ }
124
+ await verifyBranchExists(gitExec, branch);
125
+ return branch;
126
+ }
127
+
128
+ /** Verify a branch exists locally or remotely (warns but continues if not found). */
129
+ async function verifyBranchExists(gitExec: ReturnType<typeof createGitExec>, branch: string): Promise<void> {
130
+ try {
131
+ const verifyResult = await gitExec(`git rev-parse --verify refs/heads/${branch}`);
132
+ if (verifyResult.exitCode !== 0) {
133
+ log.warn(`[workflow] Branch '${branch}' does not exist locally, trying remote`);
134
+ const remoteVerify = await gitExec(`git rev-parse --verify refs/remotes/origin/${branch}`);
135
+ if (remoteVerify.exitCode !== 0) {
136
+ log.warn(`[workflow] Branch '${branch}' not found locally or remotely, using anyway`);
137
+ }
138
+ }
139
+ } catch {
140
+ // Verification failed — use the name anyway
141
+ }
142
+ }
143
+
144
+ /** Ask user where to save the branch choice and persist it. */
145
+ async function persistBranchChoice(baseBranch: string): Promise<void> {
146
+ const guard = globalThis.__piCtx;
147
+ const ui = guard?.ui;
148
+ const saveChoice = ui
149
+ ? await withAttention("workflow", ["save location", getLastMessage()].filter(Boolean).join(" • "), () =>
150
+ withCoordinator(() =>
151
+ ui.select("Save base branch setting where?", [
152
+ "Project settings (persists for all sessions)",
153
+ "This session only",
154
+ ]),
155
+ ),
156
+ )
157
+ : undefined;
158
+
159
+ // User cancelled save location dialog
160
+ if (saveChoice === undefined) {
161
+ log.info("[workflow] User cancelled save location selection, using session-only");
162
+ updateSetting("baseBranch", baseBranch, null);
163
+ return;
164
+ }
165
+
166
+ if (saveChoice.startsWith("Project")) {
167
+ updateSetting("baseBranch", baseBranch, { level: "project" });
168
+ log.info(`[workflow] Base branch '${baseBranch}' saved to project settings`);
169
+ } else {
170
+ updateSetting("baseBranch", baseBranch, null);
171
+ log.info(`[workflow] Base branch '${baseBranch}' set for this session only`);
172
+ }
173
+ }
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Worktree-related utility functions for workflow-monitor.
6
+ */
7
+
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { getSettings } from "../../settings/settings-ui.js";
10
+ import type { FeatureSession } from "../../state/feature-session.js";
11
+ import type { FeatureState } from "../../state/feature-state.js";
12
+ import { NO_STATUS } from "../../ui/featyard-widget.js";
13
+ import type { ExecFn } from "./worktree-lifecycle.js";
14
+ import { cleanupWorktreeOnFinish } from "./worktree-lifecycle.js";
15
+
16
+ const WORKTREE_ACTIVE_PHASES = new Set(["implement", "verify", "review"]);
17
+ const WORKTREE_STATUS_KEY = "worktree";
18
+
19
+ /** Folder icon shown before the worktree path in the footer status entry. */
20
+ const WORKTREE_ICON = "\u{1F4C2}";
21
+
22
+ /**
23
+ * Sync the worktree footer indicator (📂 {path}) with the current feature state.
24
+ *
25
+ * Shown when the active feature has a recorded `git.worktreePath`, the
26
+ * `branchPolicy` is `worktree`, and the feature is not yet completed. Cleared
27
+ * otherwise. This is the single source of truth for the indicator's logic;
28
+ * called both at creation time (immediate feedback) and on every UI restore
29
+ * (reload/resume/fork/reset) so the indicator never disappears after a reload
30
+ * and never lingers after completion/reset.
31
+ *
32
+ * Completion (not phase) is the off-signal: the worktree is removed in the
33
+ * finish phase, and the done state retains `worktreePath` until displaced, so a
34
+ * phase check would falsely re-show the indicator for a completed feature.
35
+ */
36
+ export function syncWorktreeStatus(featureState: FeatureState | null): void {
37
+ const guard = globalThis.__piCtx;
38
+ if (!guard?.hasUI || !guard?.ui?.setStatus) return;
39
+ const ui = guard.ui;
40
+ const worktreePath = featureState?.git?.worktreePath ?? null;
41
+ const isWorktreePolicy = getSettings().branchPolicy === "worktree";
42
+ const isDone = featureState?.completedAt != null;
43
+ if (worktreePath && isWorktreePolicy && !isDone) {
44
+ const label = `${WORKTREE_ICON} ${worktreePath}`;
45
+ ui.setStatus(WORKTREE_STATUS_KEY, ui.theme?.fg("accent", label) ?? label);
46
+ } else {
47
+ ui.setStatus(WORKTREE_STATUS_KEY, NO_STATUS);
48
+ }
49
+ }
50
+
51
+ /** Escape a string for bash single-quote context: wraps in '...', replacing internal ' with '\'' */
52
+ export function bashSingleQuote(str: string): string {
53
+ return `'${str.replace(/'/g, "'\\''")}'`;
54
+ }
55
+
56
+ /**
57
+ * Shared guard for worktree-aware handlers.
58
+ * Returns worktree context if interception should be active, null otherwise.
59
+ * Checks: active slug → worktreePath → branchPolicy=worktree → phase in WORKTREE_ACTIVE_PHASES.
60
+ */
61
+ export function getActiveWorktreeContext(handler: FeatureSession): { worktreePath: string } | null {
62
+ const slug = handler.getActiveFeatureSlug();
63
+ if (!slug) return null;
64
+ const fsData = handler.getActiveFeatureState();
65
+ const worktreePath = fsData?.git?.worktreePath;
66
+ if (!worktreePath) return null;
67
+ const settings = getSettings();
68
+ if (settings.branchPolicy !== "worktree") return null;
69
+ const ws = handler.getWorkflowState();
70
+ const phase = ws?.currentPhase;
71
+ if (!phase || !WORKTREE_ACTIVE_PHASES.has(phase)) return null;
72
+ return { worktreePath };
73
+ }
74
+
75
+ /**
76
+ * Result shape returned by any exec implementation we support.
77
+ */
78
+ interface ExecResult {
79
+ exitCode?: number;
80
+ stdout?: string;
81
+ }
82
+
83
+ /**
84
+ * Extension contexts delivered at runtime may carry an `actions` object with an
85
+ * `exec` helper (e.g. command handlers and tool execute). The SDK types do not
86
+ * declare this on the base `ExtensionContext`, so we model it as optional and
87
+ * fall back to a direct child_process exec when it is absent.
88
+ */
89
+ interface ExecCapableContext extends ExtensionContext {
90
+ actions?: { exec?: (command: string, options?: { cwd?: string | null }) => Promise<ExecResult> };
91
+ }
92
+
93
+ /**
94
+ * Create a git exec function from an extension context.
95
+ */
96
+ export function createGitExec(ctx: ExtensionContext): ExecFn {
97
+ const execCtx = ctx as ExecCapableContext;
98
+ return async (command: string, options?: { cwd?: string }) => {
99
+ // Prefer ctx.actions.exec (available in command handlers and tool execute)
100
+ if (execCtx?.actions?.exec) {
101
+ try {
102
+ const result = (await execCtx.actions.exec(command, options)) as ExecResult | undefined;
103
+ // Some exec implementations return undefined for unmocked commands; treat that as a
104
+ // non-zero exit (empty stdout) rather than crashing on property access.
105
+ if (!result) {
106
+ return { exitCode: 1, stdout: "" };
107
+ }
108
+ return { exitCode: result.exitCode ?? 0, stdout: result.stdout ?? "" };
109
+ } catch (err: unknown) {
110
+ if (err instanceof TypeError || err instanceof ReferenceError || err instanceof SyntaxError) {
111
+ throw err;
112
+ }
113
+ return { exitCode: 1, stdout: err instanceof Error ? err.message : String(err) };
114
+ }
115
+ }
116
+ // Fallback: direct exec (for contexts without actions.exec)
117
+ // Use exec (shell) instead of execFile to handle quoted arguments correctly.
118
+ // execFile with command.split(" ") breaks on arguments containing spaces.
119
+ try {
120
+ const { exec } = await import("node:child_process");
121
+ const { promisify } = await import("node:util");
122
+ const execAsync = promisify(exec);
123
+ const { stdout } = await execAsync(command, {
124
+ cwd: options?.cwd ?? undefined,
125
+ encoding: "utf-8",
126
+ });
127
+ return { exitCode: 0, stdout };
128
+ } catch (err: unknown) {
129
+ return { exitCode: 1, stdout: err instanceof Error ? err.message : String(err) };
130
+ }
131
+ };
132
+ }
133
+
134
+ /** Remove worktree when feature is done */
135
+ export async function cleanupWorktreeOnFinishWrapper(
136
+ featureState: Parameters<typeof cleanupWorktreeOnFinish>[0],
137
+ ctx: ExtensionContext,
138
+ ): Promise<void> {
139
+ return cleanupWorktreeOnFinish(featureState, createGitExec(ctx), getSettings(), WORKTREE_STATUS_KEY);
140
+ }
141
+
142
+ export { WORKTREE_STATUS_KEY };
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Register worktree tool_call interception and before_agent_start CWD override.
6
+ *
7
+ * When a worktree is active, rewrites tool paths and commands to target the worktree directory.
8
+ */
9
+
10
+ import * as path from "node:path";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { FeatureSession } from "../../state/feature-session.js";
13
+ import { bashSingleQuote, getActiveWorktreeContext } from "./worktree-helpers.js";
14
+
15
+ /** File tools that require a path parameter */
16
+ const FILE_TOOLS_REQUIRED_PATH = new Set(["read", "write", "edit"]);
17
+
18
+ /** Injected into the system prompt via before_agent_start so the agent never commits .featyard/ files. */
19
+ const FY_INSTRUCTION =
20
+ "\n\n⚠️ `.featyard/` files are auto-managed external storage — gitignored, never committed. Never `git add -f` them.";
21
+
22
+ /** File tools with an optional path parameter */
23
+ const FILE_TOOLS_OPTIONAL_PATH = new Set(["ls", "find", "grep"]);
24
+
25
+ export function registerWorktreeInterception(pi: ExtensionAPI, deps: { handler: FeatureSession }): void {
26
+ const { handler } = deps;
27
+
28
+ // --- Worktree tool_call interception (must be before guardrail handler) ---
29
+ pi.on("tool_call", async (event, _ctx) => {
30
+ const wtx = getActiveWorktreeContext(handler);
31
+ if (!wtx) return undefined;
32
+ const { worktreePath } = wtx;
33
+
34
+ if (event.toolName === "bash") {
35
+ const input = event.input as { command: string };
36
+ if (!input.command) return undefined;
37
+ input.command = `cd ${bashSingleQuote(worktreePath)} && ${input.command}`;
38
+ } else if (FILE_TOOLS_REQUIRED_PATH.has(event.toolName)) {
39
+ const input = event.input as { path: string };
40
+ if (!path.isAbsolute(input.path)) {
41
+ input.path = path.resolve(worktreePath, input.path);
42
+ }
43
+ } else if (FILE_TOOLS_OPTIONAL_PATH.has(event.toolName)) {
44
+ const input = event.input as { path?: string };
45
+ if (input.path === undefined || input.path === "") {
46
+ input.path = worktreePath;
47
+ } else if (!path.isAbsolute(input.path)) {
48
+ input.path = path.resolve(worktreePath, input.path);
49
+ }
50
+ }
51
+ return undefined;
52
+ });
53
+
54
+ // --- System prompt update for worktree CWD and .featyard/ storage warning ---
55
+ pi.on("before_agent_start", async (event, _ctx) => {
56
+ let modified = event.systemPrompt;
57
+ const wtx = getActiveWorktreeContext(handler);
58
+ if (wtx) {
59
+ modified = modified.replace(/Current working directory: .+/, `Current working directory: ${wtx.worktreePath}`);
60
+ }
61
+ // Append .featyard/ external storage instruction (idempotent — safe if multiple handlers run)
62
+ modified += FY_INSTRUCTION;
63
+ if (modified === event.systemPrompt) return undefined;
64
+ return { systemPrompt: modified };
65
+ });
66
+ }