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,462 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* .featyard junction — external artifact storage for featyard.
|
|
6
|
+
*
|
|
7
|
+
* featyard writes process artifacts (task-plans, research, reviews) OUT of the git
|
|
8
|
+
* repo into ~/.pi/featyard/artifacts/<key>/ via a repo-side junction (featyard). This keeps the
|
|
9
|
+
* repo clean + survives git-worktree removal (all worktrees of a project aggregate to
|
|
10
|
+
* ONE external key derived from the MAIN repo root).
|
|
11
|
+
*
|
|
12
|
+
* Design (pi-bck/DESIGN-FF-JUNCTION.md):
|
|
13
|
+
* - Cross-platform: `fs.symlinkSync` with type 'junction' on Windows (no admin), 'dir' elsewhere.
|
|
14
|
+
* - Git-FREE project identity: walks up from cwd for `.git` (parses linked-worktree.git files)
|
|
15
|
+
* with node:fs only — identity resolution never shells out to git. Falls back to cwd when
|
|
16
|
+
* worktrees are disabled + no.git. (The separate ignore step may shell out to
|
|
17
|
+
* `git rev-parse --git-common-dir` to locate the common dir, but falls back to fs when the git
|
|
18
|
+
* binary is absent — so featyard keeps working without git installed either way.)
|
|
19
|
+
* - Throws on real failure (visible + debuggable). Idempotent on re-run.
|
|
20
|
+
*/
|
|
21
|
+
import { execSync } from "node:child_process";
|
|
22
|
+
import {
|
|
23
|
+
cpSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
lstatSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
readlinkSync,
|
|
29
|
+
renameSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
statSync,
|
|
32
|
+
symlinkSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
|
|
37
|
+
import { log } from "../log.js";
|
|
38
|
+
|
|
39
|
+
/** Normalize backslashes to forward slashes for cross-platform path comparison. */
|
|
40
|
+
function toForwardSlashes(p: string): string {
|
|
41
|
+
return p.replace(/\\/g, "/");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Munge an absolute path into featyard's session-style key.
|
|
46
|
+
* Replaces each `:`, `\`, `/` with `-` and wraps with `--...--`.
|
|
47
|
+
* E:\sync\...\avtc-pi-featyard → --E--sync-...-avtc-pi-featyard--
|
|
48
|
+
* (Matches pi's session-folder munging scheme)
|
|
49
|
+
*/
|
|
50
|
+
export function mungeSessionKey(absPath: string): string {
|
|
51
|
+
return `--${absPath.replace(/[\\/:]/g, "-")}--`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the MAIN repo root from a working directory WITHOUT using the git binary.
|
|
56
|
+
*
|
|
57
|
+
* - `.git` is a DIRECTORY → cwd is the main worktree; return it.
|
|
58
|
+
* - `.git` is a FILE (linked worktree) → parse `gitdir: <abs>/main/.git/worktrees/<name>`,
|
|
59
|
+
* strip `/worktrees/<name>` + `/.git` to recover the main repo root.
|
|
60
|
+
* - No `.git` walking up to the fs root → return null (not a git repo).
|
|
61
|
+
*
|
|
62
|
+
* This mirrors `resolveMainRepoPath` in worktree.ts but uses only node:fs (no git subprocess),
|
|
63
|
+
* so it works wherever featyard runs, independent of git availability.
|
|
64
|
+
*
|
|
65
|
+
* @internal exported for testing
|
|
66
|
+
*/
|
|
67
|
+
export function resolveProjectRootFs(startCwd: string): string | null {
|
|
68
|
+
let dir = path.resolve(startCwd);
|
|
69
|
+
// eslint-disable-next-line no-constant-condition
|
|
70
|
+
while (true) {
|
|
71
|
+
const gitPath = path.join(dir, ".git");
|
|
72
|
+
if (existsSync(gitPath)) {
|
|
73
|
+
let isDir = false;
|
|
74
|
+
try {
|
|
75
|
+
isDir = statSync(gitPath).isDirectory();
|
|
76
|
+
} catch {
|
|
77
|
+
// stat failed — treat as not a usable.git, keep walking
|
|
78
|
+
}
|
|
79
|
+
if (isDir) {
|
|
80
|
+
// Main worktree: this directory IS the repo root.
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
// Linked worktree:.git is a file pointing at <main>/.git/worktrees/<name>.
|
|
84
|
+
try {
|
|
85
|
+
const content = readFileSync(gitPath, "utf-8").trim();
|
|
86
|
+
const match = content.match(/^gitdir:\s*(.+)$/m);
|
|
87
|
+
if (match?.[1]) {
|
|
88
|
+
const gitdir = toForwardSlashes(match[1].trim());
|
|
89
|
+
// Strip optional /worktrees/<name>, then /.git, to reach the main root.
|
|
90
|
+
const mainGitDir = gitdir.replace(/\/worktrees\/[^/]+$/, "");
|
|
91
|
+
const mainRoot = mainGitDir.replace(/\/\.git$/, "");
|
|
92
|
+
if (mainRoot && mainRoot !== mainGitDir) {
|
|
93
|
+
return mainRoot;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// unreadable.git file — fall through to using this dir
|
|
98
|
+
}
|
|
99
|
+
return dir;
|
|
100
|
+
}
|
|
101
|
+
const parent = path.dirname(dir);
|
|
102
|
+
if (parent === dir) return null; // reached filesystem root, no.git found
|
|
103
|
+
dir = parent;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface EnsureFeatyardJunctionResult {
|
|
108
|
+
/** External storage dir the.featyard junction points at. */
|
|
109
|
+
externalDir: string;
|
|
110
|
+
/** Session-style key derived from the project root. */
|
|
111
|
+
key: string;
|
|
112
|
+
/** True if the junction was created this call; false if it already existed (idempotent). */
|
|
113
|
+
created: boolean;
|
|
114
|
+
/** How the project root was resolved: "git" (walked to.git) or "cwd" (fallback, no.git). */
|
|
115
|
+
rootSource: "git" | "cwd";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Resolve the sibling archive base for a junction result: `<externalDir-sibling>/artifacts-archive/<key>`.
|
|
119
|
+
* Single source of truth for the archiveBase derivation — shared by the background sweep
|
|
120
|
+
* (workflow-monitor.ts) and the /fy:archive-artifacts manual command (workflow-commands.ts). */
|
|
121
|
+
export function resolveArchiveBase(jr: EnsureFeatyardJunctionResult): string {
|
|
122
|
+
return path.join(path.dirname(jr.externalDir), "artifacts-archive", jr.key);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the two design-doc roots swept by the design-doc archive: the out-of-repo `.featyard/designs`
|
|
127
|
+
* (local mode, via the junction → `externalDir/designs`) and the in-repo `docs/featyard/designs`
|
|
128
|
+
* (committed mode). Shared by the manual `/fy:archive-designs` command and the background sweep so
|
|
129
|
+
* both always sweep the same pair.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveDesignsDirs(externalDir: string, cwd: string): string[] {
|
|
132
|
+
return [path.join(externalDir, "designs"), path.join(cwd, "docs", "featyard", "designs")];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One-time migration of the external storage base from the feature-flow era:
|
|
137
|
+
* `~/.pi/feature-flow` → `~/.pi/featyard`. Idempotent (no-op once `~/.pi/featyard` exists).
|
|
138
|
+
* Best-effort: if another process still holds the old base open (e.g. a running old build), the
|
|
139
|
+
* rename is logged + skipped and retried on the next load — it never propagates.
|
|
140
|
+
*/
|
|
141
|
+
function migrateLegacyBase(homeDir: string): void {
|
|
142
|
+
const oldBase = path.join(homeDir, ".pi", "feature-flow");
|
|
143
|
+
const newBase = path.join(homeDir, ".pi", "featyard");
|
|
144
|
+
if (!existsSync(oldBase) || existsSync(newBase)) return;
|
|
145
|
+
try {
|
|
146
|
+
renameSync(oldBase, newBase);
|
|
147
|
+
log.info(`[featyard] migrated external storage base ${oldBase} → ${newBase}`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log.warn(
|
|
150
|
+
`[featyard] could not migrate base ${oldBase} → ${newBase} (skipping): ${err instanceof Error ? err.message : err}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One-time per-repo migration from the feature-flow era: a repo-local `.ff` entry (junction or
|
|
157
|
+
* real dir) is superseded by `.featyard`. Called after the external dir exists and before the
|
|
158
|
+
* `.featyard` link is (re)created.
|
|
159
|
+
* - `.ff` link (junction/symlink): stale (points at the old `~/.pi/feature-flow` base) → remove
|
|
160
|
+
* the LINK only (never its target).
|
|
161
|
+
* - `.ff` real dir: rare (artifacts written in-repo when junction setup had been bypassed) →
|
|
162
|
+
* merge its contents into the external storage dir, then remove it so the `.featyard` junction
|
|
163
|
+
* supersedes it without data loss.
|
|
164
|
+
* Best-effort: any failure is logged + swallowed (never blocks junction setup).
|
|
165
|
+
*/
|
|
166
|
+
function migrateLegacyFeatyardLink(cwd: string, externalDir: string): void {
|
|
167
|
+
const ff = path.join(cwd, ".ff");
|
|
168
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
169
|
+
try {
|
|
170
|
+
stat = lstatSync(ff); // lstat: does not follow a junction/symlink
|
|
171
|
+
} catch {
|
|
172
|
+
return; // no .ff — nothing to migrate
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
if (stat.isSymbolicLink()) {
|
|
176
|
+
rmSync(ff, { force: true }); // remove the link only, never its target
|
|
177
|
+
log.info("[featyard] removed legacy .ff junction (superseded by .featyard)");
|
|
178
|
+
} else if (stat.isDirectory()) {
|
|
179
|
+
cpSync(ff, externalDir, { recursive: true, force: true }); // merge contents into external store
|
|
180
|
+
rmSync(ff, { recursive: true, force: true });
|
|
181
|
+
log.info("[featyard] merged legacy real .ff directory into external storage and removed it");
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
log.warn(
|
|
185
|
+
`[featyard] could not migrate legacy .ff at ${ff} (skipping): ${err instanceof Error ? err.message : err}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** How to heal a plain real-directory `.featyard` (not a link) encountered during setup. */
|
|
191
|
+
export type FeatyardRealDirMode = "rename" | "delete";
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Idempotently ensure the repo-side `.featyard` junction exists and points at the project's
|
|
195
|
+
* external artifact storage under `~/.pi/featyard/artifacts/<key>/`.
|
|
196
|
+
*
|
|
197
|
+
* Throws if:
|
|
198
|
+
* - branchPolicy is "worktree" but no `.git` is found walking up from cwd (worktrees need git).
|
|
199
|
+
* - an existing `.featyard` entry cannot be removed for re-pointing (visible + debuggable).
|
|
200
|
+
* - the underlying `fs.symlinkSync` fails (permissions, invalid path, unsupported FS).
|
|
201
|
+
*
|
|
202
|
+
* `onRealDir` controls healing of a plain real-directory `.featyard` (not a link):
|
|
203
|
+
* - "rename": move it aside to `.featyard.pre-junction-<ts>` so its contents survive (safe default
|
|
204
|
+
* for the main-repo/init path — never loses data).
|
|
205
|
+
* - "delete": remove it + replace with the junction (worktree checkout — content is also in
|
|
206
|
+
* git + the external store, so deletion is safe and avoids stale backup dirs in worktrees).
|
|
207
|
+
* Link entries (wrong/stale target) are ALWAYS re-pointed by removing the link only (never the
|
|
208
|
+
* target), regardless of `onRealDir`.
|
|
209
|
+
*
|
|
210
|
+
* All params are required by design (no silent defaults) — callers pass explicit values,
|
|
211
|
+
* including `process.platform` for link-type selection. Never shells out to git; never falls
|
|
212
|
+
* back silently on link failure.
|
|
213
|
+
*/
|
|
214
|
+
export function ensureFeatyardJunction(
|
|
215
|
+
cwd: string,
|
|
216
|
+
branchPolicy: string,
|
|
217
|
+
homeDir: string,
|
|
218
|
+
onRealDir: FeatyardRealDirMode,
|
|
219
|
+
): EnsureFeatyardJunctionResult {
|
|
220
|
+
const resolvedCwd = path.resolve(cwd);
|
|
221
|
+
|
|
222
|
+
// 0. One-time base migration from the feature-flow era: ~/.pi/feature-flow -> ~/.pi/featyard.
|
|
223
|
+
// Best-effort + idempotent: a no-op once ~/.pi/featyard exists, and if another process still
|
|
224
|
+
// holds the old base open the rename is skipped and retried on the next load. Runs before
|
|
225
|
+
// externalDir is used so the migrated tree is in place.
|
|
226
|
+
migrateLegacyBase(homeDir);
|
|
227
|
+
|
|
228
|
+
// 1. Resolve project root (git-free).
|
|
229
|
+
let projectRoot = resolveProjectRootFs(resolvedCwd);
|
|
230
|
+
let rootSource: "git" | "cwd" = "git";
|
|
231
|
+
if (projectRoot === null) {
|
|
232
|
+
if (branchPolicy === "worktree") {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`[featyard] branchPolicy "worktree" requires a git repository, ` +
|
|
235
|
+
`but no .git was found walking up from ${resolvedCwd}. ` +
|
|
236
|
+
`Run featyard inside a git repo, or switch branchPolicy to "current-branch".`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
// worktrees off + not a git repo → use cwd as the project identity (no git needed).
|
|
240
|
+
projectRoot = resolvedCwd;
|
|
241
|
+
rootSource = "cwd";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 2. Munge to session-style key + locate external storage.
|
|
245
|
+
const key = mungeSessionKey(projectRoot);
|
|
246
|
+
const externalDir = path.join(homeDir, ".pi", "featyard", "artifacts", key);
|
|
247
|
+
|
|
248
|
+
// 3. Create the external storage dir (idempotent mkdir). Standard subdirs
|
|
249
|
+
// (task-plans/research/reviews/feature-state) are NOT pre-created: every writer
|
|
250
|
+
// either mkdir's its own parent (feature-state save, dated-review path) or goes through
|
|
251
|
+
// the write/edit tools (which auto-create parents), and every reader guards a missing
|
|
252
|
+
// dir. Pre-creating them only littered fresh/test projects with empty folders.
|
|
253
|
+
mkdirSync(externalDir, { recursive: true });
|
|
254
|
+
|
|
255
|
+
// 3b. One-time migration from the feature-flow era: a repo-local `.ff` entry (junction or
|
|
256
|
+
// real dir) is superseded by `.featyard`. Best-effort; never blocks junction setup.
|
|
257
|
+
migrateLegacyFeatyardLink(resolvedCwd, externalDir);
|
|
258
|
+
|
|
259
|
+
// 4. Ensure the.featyard entry points at THIS project's external dir (idempotent + self-healing).
|
|
260
|
+
// Windows: junction (no admin); else: directory symlink.
|
|
261
|
+
// Re-point anything that isn't the correct junction:
|
|
262
|
+
// - nothing exists → create
|
|
263
|
+
// - a link to a wrong/stale place → remove the LINK (non-recursive, never touches its
|
|
264
|
+
// target) + re-create (a foreign dir is never clobbered)
|
|
265
|
+
// - a plain real directory → controlled by `onRealDir`:
|
|
266
|
+
// "rename" (default): move it aside to.featyard.pre-junction-<ts> (preserve contents)
|
|
267
|
+
// "delete": remove + create (worktree checkout; content also in git + external store)
|
|
268
|
+
// Throws only if an existing entry cannot be moved/removed (visible + debuggable).
|
|
269
|
+
const featyardLink = path.join(resolvedCwd, ".featyard");
|
|
270
|
+
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
271
|
+
let created = false;
|
|
272
|
+
const currentTarget = readLinkTarget(featyardLink); // link target, or null if not a link / absent
|
|
273
|
+
|
|
274
|
+
if (currentTarget !== null && samePath(currentTarget, externalDir)) {
|
|
275
|
+
// Already correctly linked — idempotent no-op.
|
|
276
|
+
} else {
|
|
277
|
+
// currentTarget !== null means a link entry exists (even a stale one whose target is gone,
|
|
278
|
+
// since existsSync follows links and reads false then). Check it first so we short-circuit
|
|
279
|
+
// the extra existsSync syscall in the common link case.
|
|
280
|
+
const isLink = currentTarget !== null;
|
|
281
|
+
const isRealDir = !isLink && existsSync(featyardLink);
|
|
282
|
+
const hadExisting = isLink || isRealDir;
|
|
283
|
+
let renamedTo: string | null = null;
|
|
284
|
+
if (hadExisting) {
|
|
285
|
+
try {
|
|
286
|
+
if (isLink) {
|
|
287
|
+
// Remove a LINK non-recursively — never touches its target (guaranteed across Node
|
|
288
|
+
// versions / platforms). A foreign dir is never clobbered.
|
|
289
|
+
rmSync(featyardLink, { force: true });
|
|
290
|
+
} else if (onRealDir === "rename") {
|
|
291
|
+
// A stray real-dir.featyard (not from a worktree checkout). Preserve it — move it aside so
|
|
292
|
+
// its contents survive on disk for manual recovery. Find a non-clashing backup name.
|
|
293
|
+
renamedTo = uniqueBackupPath(featyardLink);
|
|
294
|
+
renameSync(featyardLink, renamedTo);
|
|
295
|
+
} else {
|
|
296
|
+
// Worktree checkout: the real-dir.featyard's content is also in git + the external store,
|
|
297
|
+
// so deleting it is safe (approved). Remove recursively.
|
|
298
|
+
rmSync(featyardLink, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`[featyard] .featyard at ${featyardLink} could not be removed for re-pointing to ${externalDir}. Remove it manually and re-run.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
symlinkSync(externalDir, featyardLink, linkType); // throws on failure (visible + debuggable)
|
|
307
|
+
created = true;
|
|
308
|
+
if (renamedTo) {
|
|
309
|
+
log.info(
|
|
310
|
+
`[featyard] backed up existing .featyard directory → ${renamedTo} before creating junction → ${externalDir}`,
|
|
311
|
+
);
|
|
312
|
+
} else if (hadExisting) {
|
|
313
|
+
log.info(
|
|
314
|
+
`[featyard] re-pointed .featyard junction → ${externalDir} (was ${currentTarget ?? "a plain directory"})`,
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
log.info(`[featyard] created .featyard junction → ${externalDir}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 5. Ensure the.featyard junction is locally ignored via the repo's `.git/info/exclude` (the
|
|
322
|
+
// clone-local ignore file), never the shared `.gitignore`, so contributing to other authors'
|
|
323
|
+
// repos leaves their tracked files pristine. Idempotent + fail-safe (any failure is logged
|
|
324
|
+
// and skipped — never breaks junction setup). Only inside a git repo.
|
|
325
|
+
if (rootSource === "git") {
|
|
326
|
+
ensureFeatyardLocallyIgnored(resolvedCwd, projectRoot);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { externalDir, key, created, rootSource };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Ensure the `.featyard` junction is locally ignored via the repo's `.git/info/exclude` (the
|
|
334
|
+
* clone-local ignore file under the git common dir), NOT the shared `.gitignore`. featyard's
|
|
335
|
+
* `.featyard/` is per-clone external storage, so its ignore rule belongs in the per-clone ignore file —
|
|
336
|
+
* this keeps a `.gitignore` edit out of other authors' repos (no PR pollution, no maintainer
|
|
337
|
+
* friction). A single entry in the common dir covers ALL linked worktrees.
|
|
338
|
+
*
|
|
339
|
+
* Idempotent: appends a labeled block if `.featyard` is missing, and strips any legacy
|
|
340
|
+
* feature-flow `.ff` line + its comment (the junction was renamed to `.featyard`); a no-op write
|
|
341
|
+
* once clean (an existing `.featyard` or legacy `.featyard/` line counts as already ignored). Fail-safe:
|
|
342
|
+
* any failure (git binary absent, unreadable exclude, read-only FS) is logged and skipped — it never
|
|
343
|
+
* propagates out of `ensureFeatyardJunction`, which has already succeeded.
|
|
344
|
+
*
|
|
345
|
+
* Uses `.featyard` (no trailing slash): the junction is a symlink/junction, not a plain directory, and
|
|
346
|
+
* the trailing-slash form (`.featyard/`) only matches directories — so it fails to ignore the junction
|
|
347
|
+
* path itself.
|
|
348
|
+
*/
|
|
349
|
+
function ensureFeatyardLocallyIgnored(cwd: string, projectRoot: string): void {
|
|
350
|
+
const gitDir = resolveCommonGitDir(cwd, projectRoot);
|
|
351
|
+
if (!gitDir) return; // no usable git dir — skip silently (best-effort)
|
|
352
|
+
|
|
353
|
+
const excludePath = path.join(gitDir, "info", "exclude");
|
|
354
|
+
try {
|
|
355
|
+
const ENTRY = ".featyard";
|
|
356
|
+
const FY_COMMENT = "# featyard artifact junction (external storage)";
|
|
357
|
+
/** True if a line already ignores the junction (`.featyard` or legacy `.featyard/`). */
|
|
358
|
+
const isFeatyardEntry = (line: string): boolean => {
|
|
359
|
+
const t = line.trim();
|
|
360
|
+
return t === ".featyard" || t === ".featyard/";
|
|
361
|
+
};
|
|
362
|
+
/** True for a legacy feature-flow `.ff` line or its comment (superseded by `.featyard`). */
|
|
363
|
+
const isLegacyFfLine = (line: string): boolean => {
|
|
364
|
+
const t = line.trim();
|
|
365
|
+
return t === ".ff" || t === ".ff/" || t === "# feature-flow artifact junction (external storage)";
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
let existing = "";
|
|
369
|
+
let hadFile = false;
|
|
370
|
+
if (existsSync(excludePath)) {
|
|
371
|
+
hadFile = true;
|
|
372
|
+
existing = readFileSync(excludePath, "utf-8");
|
|
373
|
+
} else {
|
|
374
|
+
// info/ always exists in a real repo; create it (idempotently) for the unusual case it doesn't.
|
|
375
|
+
mkdirSync(path.join(gitDir, "info"), { recursive: true });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Drop legacy feature-flow `.ff` lines + comment (the junction was renamed to `.featyard`),
|
|
379
|
+
// preserving every other line/order. Then ensure `.featyard` is present.
|
|
380
|
+
const allLines = existing.split(/\r?\n/);
|
|
381
|
+
const kept = allLines.filter((ln) => !isLegacyFfLine(ln));
|
|
382
|
+
const hasFeatyard = kept.some(isFeatyardEntry);
|
|
383
|
+
// Already clean + ignored, and nothing legacy to strip → nothing to write (idempotent).
|
|
384
|
+
if (hadFile && hasFeatyard && kept.length === allLines.length) return;
|
|
385
|
+
let body = kept.join("\n");
|
|
386
|
+
if (body && !body.endsWith("\n")) body += "\n";
|
|
387
|
+
if (!hasFeatyard) body += `\n${FY_COMMENT}\n${ENTRY}\n`;
|
|
388
|
+
|
|
389
|
+
writeFileSync(excludePath, body, "utf-8");
|
|
390
|
+
} catch (err) {
|
|
391
|
+
log.warn(`[featyard] could not update ${excludePath} (skipping): ${err instanceof Error ? err.message : err}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Resolve the git common dir that holds the shared `info/exclude`.
|
|
397
|
+
*
|
|
398
|
+
* 1. Authoritative: `git rev-parse --git-common-dir` — correct across submodules, GIT_DIR, and
|
|
399
|
+
* linked worktrees. Wrapped so a missing/unusable git binary never breaks setup.
|
|
400
|
+
* 2. fs-only fallback: `<projectRoot>/.git` — the main/common dir for standard main-repo and
|
|
401
|
+
* linked-worktree layouts. Used when the git binary is absent (the same path that keeps `.featyard`
|
|
402
|
+
* ignored without git installed).
|
|
403
|
+
*
|
|
404
|
+
* Returns null when neither resolves a usable directory.
|
|
405
|
+
*/
|
|
406
|
+
function resolveCommonGitDir(cwd: string, projectRoot: string): string | null {
|
|
407
|
+
try {
|
|
408
|
+
const out = execSync("git rev-parse --git-common-dir", {
|
|
409
|
+
encoding: "utf-8",
|
|
410
|
+
cwd,
|
|
411
|
+
}).trim();
|
|
412
|
+
// rev-parse returns a path relative to cwd (e.g. ".git", "../../.git") — resolve it.
|
|
413
|
+
if (out) {
|
|
414
|
+
const resolved = path.resolve(cwd, out);
|
|
415
|
+
if (existsSync(resolved)) return resolved;
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
// git binary absent / not a usable repo → fall through to the fs fallback.
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const fsGitDir = path.join(projectRoot, ".git");
|
|
422
|
+
if (existsSync(fsGitDir) && statSync(fsGitDir).isDirectory()) return fsGitDir;
|
|
423
|
+
} catch {
|
|
424
|
+
// ignore
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Read a junction/symlink target, returning null if unreadable or not a link. */
|
|
430
|
+
function readLinkTarget(linkPath: string): string | null {
|
|
431
|
+
try {
|
|
432
|
+
return readlinkSync(linkPath);
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Compare two paths for equality after normalization (forward slashes; case-insensitive on Windows). */
|
|
439
|
+
function samePath(a: string | null, b: string): boolean {
|
|
440
|
+
if (!a) return false;
|
|
441
|
+
const na = toForwardSlashes(path.resolve(a));
|
|
442
|
+
const nb = toForwardSlashes(path.resolve(b));
|
|
443
|
+
if (process.platform === "win32") {
|
|
444
|
+
return na.toLowerCase() === nb.toLowerCase();
|
|
445
|
+
}
|
|
446
|
+
return na === nb;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Build a non-clashing backup path for an existing `.featyard` real directory: `.featyard.pre-junction-<ts>`,
|
|
451
|
+
* appending `-<n>` until a free name is found (so repeated heals never clobber earlier backups).
|
|
452
|
+
*/
|
|
453
|
+
function uniqueBackupPath(featyardLink: string): string {
|
|
454
|
+
const base = `${featyardLink}.pre-junction-${Date.now()}`;
|
|
455
|
+
let candidate = base;
|
|
456
|
+
let n = 1;
|
|
457
|
+
while (existsSync(candidate)) {
|
|
458
|
+
candidate = `${base}-${n}`;
|
|
459
|
+
n++;
|
|
460
|
+
}
|
|
461
|
+
return candidate;
|
|
462
|
+
}
|