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,312 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `.featyard/` force-add guardrail.
|
|
6
|
+
*
|
|
7
|
+
* The `.featyard/` directory is a junction to featyard's external artifact storage
|
|
8
|
+
* (~/.pi/featyard/artifacts/<key>/). It is auto-created on extension load and
|
|
9
|
+
* auto-added to `.gitignore`. Its contents (task-plans, research, reviews,
|
|
10
|
+
* known-issues) must NEVER enter the repo history.
|
|
11
|
+
*
|
|
12
|
+
* Agents following review/commit skill prompts sometimes `git add -f` a `.featyard/` file
|
|
13
|
+
* to bypass the ignore. This guardrail hard-blocks any `git add` that uses
|
|
14
|
+
* `-f`/`--force` and would pull in `.featyard/` paths — explicitly (a path resolving
|
|
15
|
+
* under `.featyard/`) or via a force-sweep (`-fA`, `-f --all`, `-f .` when `.featyard/` is
|
|
16
|
+
* under cwd). `.featyard/` always exists when featyard is active, so a force-sweep
|
|
17
|
+
* unconditionally includes it.
|
|
18
|
+
*
|
|
19
|
+
* Pure (parser) + fs-injected (sweep cwd checks) so the parser is unit-testable
|
|
20
|
+
* without a filesystem; the checker accepts a `fs` seam for fake-fs tests.
|
|
21
|
+
*/
|
|
22
|
+
import * as fsType from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
|
|
25
|
+
import { decomposeWithCwd } from "./shell-decompose.js";
|
|
26
|
+
|
|
27
|
+
/** Default filesystem reference for sweep cwd checks. */
|
|
28
|
+
export const DEFAULT_FS: typeof fsType = fsType;
|
|
29
|
+
|
|
30
|
+
export interface BlockResult {
|
|
31
|
+
block: true;
|
|
32
|
+
reason: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GitAddParse {
|
|
36
|
+
/** Always true when returned (parser returns null for non-git-add commands). */
|
|
37
|
+
isGitAdd: boolean;
|
|
38
|
+
/** `-f` / `--force` (or a combined short cluster containing `f`). */
|
|
39
|
+
force: boolean;
|
|
40
|
+
/** `-A` / `--all` / `--no-ignore-removal` (or a cluster containing `A`). */
|
|
41
|
+
sweepAll: boolean;
|
|
42
|
+
/** Positional pathspec tokens (excluding flags), before `--` resolution. */
|
|
43
|
+
pathspecs: string[];
|
|
44
|
+
/** `git -C <dir>` global option before `add`, if present (cwd override). null otherwise. */
|
|
45
|
+
gitCDir: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Normalize backslashes to forward slashes for cross-platform path comparison. */
|
|
49
|
+
function toFwd(p: string): string {
|
|
50
|
+
return p.replace(/\\/g, "/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True if a resolved absolute path has `.featyard` as a path segment (targets the junction). */
|
|
54
|
+
function targetsFeatyardSegment(absPath: string): boolean {
|
|
55
|
+
const segs = toFwd(absPath).split("/");
|
|
56
|
+
return segs.includes(".featyard");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tokenize a shell argument string into argv tokens, respecting single and
|
|
61
|
+
* double quotes. Backslashes are treated as literal path separators (NOT shell
|
|
62
|
+
* escapes) so Windows backslash paths survive intact for `.featyard/` detection — git
|
|
63
|
+
* pathspecs canonically use forward slashes, but agents on Windows may write
|
|
64
|
+
* backslash paths. For embedded spaces, agents use quotes (handled).
|
|
65
|
+
*/
|
|
66
|
+
function tokenizeArgs(input: string): string[] {
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
let buf = "";
|
|
69
|
+
let i = 0;
|
|
70
|
+
let hasBuf = false;
|
|
71
|
+
while (i < input.length) {
|
|
72
|
+
const ch = input[i];
|
|
73
|
+
if (ch === "'") {
|
|
74
|
+
hasBuf = true;
|
|
75
|
+
i++;
|
|
76
|
+
while (i < input.length && input[i] !== "'") {
|
|
77
|
+
buf += input[i];
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
i++; // skip closing quote
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === '"') {
|
|
84
|
+
hasBuf = true;
|
|
85
|
+
i++;
|
|
86
|
+
while (i < input.length && input[i] !== '"') {
|
|
87
|
+
if (input[i] === "\\" && i + 1 < input.length) {
|
|
88
|
+
buf += input[i + 1];
|
|
89
|
+
i += 2;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
buf += input[i];
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
i++; // skip closing quote
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
99
|
+
// Literal backslash (path separator on Windows) — NOT a shell escape.
|
|
100
|
+
buf += input[i];
|
|
101
|
+
hasBuf = true;
|
|
102
|
+
i += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
106
|
+
if (hasBuf) {
|
|
107
|
+
out.push(buf);
|
|
108
|
+
buf = "";
|
|
109
|
+
hasBuf = false;
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
buf += ch;
|
|
115
|
+
hasBuf = true;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
if (hasBuf) out.push(buf);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Short git-add flags that imply force / sweep-all (case-sensitive uppercase A). */
|
|
123
|
+
const FORCE_SHORT = "f";
|
|
124
|
+
const SWEEP_SHORT = "A";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse a single (already-split) shell subcommand into a git-add descriptor.
|
|
128
|
+
* Returns null if the subcommand is not a `git ... add` invocation.
|
|
129
|
+
*
|
|
130
|
+
* Recognizes:
|
|
131
|
+
* - `git add`, `git -C <dir> add` (cwd override captured as gitCDir)
|
|
132
|
+
* - `-f` / `--force` (and combined short clusters containing `f`)
|
|
133
|
+
* - `-A` / `--all` / `--no-ignore-removal` (and clusters containing `A`)
|
|
134
|
+
* - positional pathspecs; `--` terminates option parsing
|
|
135
|
+
*/
|
|
136
|
+
export function parseGitAddCommand(subcommand: string): GitAddParse | null {
|
|
137
|
+
const tokens = tokenizeArgs(subcommand);
|
|
138
|
+
if (tokens.length === 0 || tokens[0] !== "git") return null;
|
|
139
|
+
|
|
140
|
+
let force = false;
|
|
141
|
+
let sweepAll = false;
|
|
142
|
+
let gitCDir: string | null = null;
|
|
143
|
+
let addFound = false;
|
|
144
|
+
const pathspecs: string[] = [];
|
|
145
|
+
let i = 1;
|
|
146
|
+
let optionsEnded = false;
|
|
147
|
+
|
|
148
|
+
// Scan up to and including the `add` subcommand, collecting global git options
|
|
149
|
+
// (only `-C <dir>` matters here). Everything after `add` is add-specific.
|
|
150
|
+
while (i < tokens.length) {
|
|
151
|
+
const tok = tokens[i];
|
|
152
|
+
|
|
153
|
+
// Pre-add: locate the `add` subcommand, capturing `git -C <dir>`.
|
|
154
|
+
if (!addFound) {
|
|
155
|
+
if (tok === "add") {
|
|
156
|
+
addFound = true;
|
|
157
|
+
i++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (tok === "-C" || tok === "--git-dir" || tok === "--work-tree") {
|
|
161
|
+
// takes a value — but only -C is meaningful as a cwd override
|
|
162
|
+
if (tok === "-C" && i + 1 < tokens.length) gitCDir = tokens[i + 1];
|
|
163
|
+
i += 2;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// Unknown global git option before `add` — skip it (and an attached =value).
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Post-add argument parsing.
|
|
172
|
+
if (optionsEnded) {
|
|
173
|
+
pathspecs.push(tok);
|
|
174
|
+
i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (tok === "--") {
|
|
178
|
+
optionsEnded = true;
|
|
179
|
+
i++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (tok === "--force") {
|
|
183
|
+
force = true;
|
|
184
|
+
i++;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (tok === "--all" || tok === "--no-ignore-removal") {
|
|
188
|
+
sweepAll = true;
|
|
189
|
+
i++;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (tok.startsWith("--")) {
|
|
193
|
+
// Long option we don't track (e.g. --chmod=+x). Skip; `=value` is inline.
|
|
194
|
+
i++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (tok.startsWith("-") && tok.length > 1) {
|
|
198
|
+
// Short flag cluster, e.g. -fA, -Af, -fan, -fn.
|
|
199
|
+
for (const c of tok.slice(1)) {
|
|
200
|
+
if (c === FORCE_SHORT) force = true;
|
|
201
|
+
else if (c === SWEEP_SHORT) sweepAll = true;
|
|
202
|
+
// other short flags (n, p, v, ...) ignored
|
|
203
|
+
}
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// Bare "-" or positional → pathspec.
|
|
208
|
+
pathspecs.push(tok);
|
|
209
|
+
i++;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!addFound) return null;
|
|
213
|
+
return { isGitAdd: true, force, sweepAll, pathspecs, gitCDir };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Resolve a pathspec against effective cwd and check whether it lands under `.featyard/`. */
|
|
217
|
+
function pathspecTargetsFeatyard(pathspec: string, effectiveCwd: string): boolean {
|
|
218
|
+
// Normalize separators so Windows backslash pathspecs resolve consistently,
|
|
219
|
+
// then resolve `..`/`.` lexically against effective cwd.
|
|
220
|
+
const resolved = path.resolve(effectiveCwd, toFwd(pathspec));
|
|
221
|
+
return targetsFeatyardSegment(resolved);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Resolve the effective cwd for a parsed command: `git -C <dir>` overrides the tracked cwd. */
|
|
225
|
+
function resolveEffectiveCwd(trackedCwd: string | null, baseCwd: string, parse: GitAddParse): string {
|
|
226
|
+
if (parse.gitCDir !== null) return path.resolve(trackedCwd ?? baseCwd, parse.gitCDir);
|
|
227
|
+
return trackedCwd ?? baseCwd;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True if a `.featyard` directory exists directly under `dir` (cwd-sweep would include it). */
|
|
231
|
+
function featyardUnderDir(dir: string, fs: typeof fsType): boolean {
|
|
232
|
+
return fs.existsSync(path.join(dir, ".featyard"));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Check a full (possibly compound) bash command for a `.featyard/` force-add.
|
|
236
|
+
*
|
|
237
|
+
* Decomposes compound commands (`cd sub && git add ...`) tracking effective cwd,
|
|
238
|
+
* parses each `git add`, and returns a hard block if any subcommand force-adds a
|
|
239
|
+
* `.featyard/` path or force-sweeps while `.featyard/` would be included.
|
|
240
|
+
*
|
|
241
|
+
* @param command raw bash command string
|
|
242
|
+
* @param baseCwd the process cwd (base for `cd` resolution)
|
|
243
|
+
* @param fs filesystem seam (defaults to the real fs)
|
|
244
|
+
*/
|
|
245
|
+
export function checkFeatyardForceAdd(command: string, baseCwd: string, fs: typeof fsType): BlockResult | null {
|
|
246
|
+
const subcommands = decomposeWithCwd(command, baseCwd);
|
|
247
|
+
|
|
248
|
+
for (const { command: sub, effectiveCwd: trackedCwd } of subcommands) {
|
|
249
|
+
const parse = parseGitAddCommand(sub);
|
|
250
|
+
if (!parse?.force) continue;
|
|
251
|
+
|
|
252
|
+
const effectiveCwd = resolveEffectiveCwd(trackedCwd, baseCwd, parse);
|
|
253
|
+
|
|
254
|
+
// 1. Explicit pathspec targeting .featyard/ → block.
|
|
255
|
+
for (const spec of parse.pathspecs) {
|
|
256
|
+
if (spec === ".") {
|
|
257
|
+
// cwd sweep: block only if .featyard is directly under effective cwd
|
|
258
|
+
if (featyardUnderDir(effectiveCwd, fs)) {
|
|
259
|
+
return blockResult(spec, effectiveCwd);
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (pathspecTargetsFeatyard(spec, effectiveCwd)) {
|
|
264
|
+
return blockResult(spec, effectiveCwd);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 2. Force-sweep (-A/--all) → .featyard always exists when featyard is active,
|
|
269
|
+
// so a repo-wide force-add unconditionally pulls it in. Guard defensively:
|
|
270
|
+
// only block if a .featyard exists anywhere up the tree from effective cwd.
|
|
271
|
+
if (parse.sweepAll && parse.pathspecs.length === 0) {
|
|
272
|
+
if (featyardExistsUpTree(effectiveCwd, fs)) {
|
|
273
|
+
return blockResultSweep();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Walk up from `dir` looking for a `.featyard` directory (the junction is at the repo/session root). */
|
|
282
|
+
function featyardExistsUpTree(startDir: string, fs: typeof fsType): boolean {
|
|
283
|
+
let dir = path.resolve(startDir);
|
|
284
|
+
// eslint-disable-next-line no-constant-condition
|
|
285
|
+
while (true) {
|
|
286
|
+
if (fs.existsSync(path.join(dir, ".featyard"))) return true;
|
|
287
|
+
const parent = path.dirname(dir);
|
|
288
|
+
if (parent === dir) return false;
|
|
289
|
+
dir = parent;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function blockResult(pathspec: string, effectiveCwd: string): BlockResult {
|
|
294
|
+
return {
|
|
295
|
+
block: true,
|
|
296
|
+
reason:
|
|
297
|
+
"Refused: `git add -f` targets the `.featyard/` junction (external artifact storage). " +
|
|
298
|
+
"`.featyard/` is gitignored and must never be committed. " +
|
|
299
|
+
`Resolved \`${pathspec}\` under ${effectiveCwd} lands in \`.featyard/\`. ` +
|
|
300
|
+
"Stage only your source changes (e.g. `git add <path>` without `-f`).",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function blockResultSweep(): BlockResult {
|
|
305
|
+
return {
|
|
306
|
+
block: true,
|
|
307
|
+
reason:
|
|
308
|
+
"Refused: `git add -f -A` (force-sweep) would pull in the gitignored `.featyard/` junction. " +
|
|
309
|
+
"`.featyard/` is external artifact storage and must never be committed. " +
|
|
310
|
+
"Stage only your source changes (e.g. `git add -A` without `-f`).",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guardrail escalation tracking.
|
|
6
|
+
*
|
|
7
|
+
* Manages strike counts and session-level allow-lists for guardrail violations.
|
|
8
|
+
* First strike for unrecoverable types blocks; for recoverable types allows.
|
|
9
|
+
* Second+ strike prompts the user via escalation dialog.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { getActiveFeatureSlug } from "../shared/workflow-refs.js";
|
|
14
|
+
import type { ViolationType } from "../shared/workflow-types.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 { UNRECOVERABLE_TYPES } from "./guardrail-violations.js";
|
|
19
|
+
|
|
20
|
+
export class GuardrailTracker {
|
|
21
|
+
private strikes = new Map<ViolationType, number>();
|
|
22
|
+
private sessionAllowed = new Set<ViolationType>();
|
|
23
|
+
|
|
24
|
+
async maybeEscalate(
|
|
25
|
+
type: ViolationType,
|
|
26
|
+
ctx: ExtensionContext,
|
|
27
|
+
toolContext: string | null,
|
|
28
|
+
): Promise<"allow" | "block"> {
|
|
29
|
+
if (isSubagentSession()) return "allow";
|
|
30
|
+
if (this.sessionAllowed.has(type)) return "allow";
|
|
31
|
+
|
|
32
|
+
const current = this.strikes.get(type) ?? 0;
|
|
33
|
+
this.strikes.set(type, current + 1);
|
|
34
|
+
|
|
35
|
+
if (current < 1) {
|
|
36
|
+
return UNRECOVERABLE_TYPES.has(type) ? "block" : "allow";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const slug = getActiveFeatureSlug();
|
|
40
|
+
const label = type.replace(/-/g, " ");
|
|
41
|
+
const baseDetail = toolContext ? `guardrail: ${label}\n${toolContext}` : `guardrail: ${label}`;
|
|
42
|
+
const detail = [baseDetail, slug, getLastMessage()].filter(Boolean).join(" • ");
|
|
43
|
+
// Use guard's stashed ui (survives runner invalidation) when available, fall back to ctx.ui
|
|
44
|
+
const ui = globalThis.__piCtx?.ui ?? ctx.ui;
|
|
45
|
+
const choice = await withAttention("workflow", detail, () =>
|
|
46
|
+
withCoordinator(() =>
|
|
47
|
+
ui?.select(`The agent has repeatedly triggered "${label}" guardrail. Allow it to continue?`, [
|
|
48
|
+
"Yes, continue",
|
|
49
|
+
"Yes, allow all for this session",
|
|
50
|
+
"No, stop",
|
|
51
|
+
]),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (choice === "Yes, continue") {
|
|
56
|
+
this.strikes.set(type, 0);
|
|
57
|
+
return "allow";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (choice === "Yes, allow all for this session") {
|
|
61
|
+
this.sessionAllowed.add(type);
|
|
62
|
+
return "allow";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return "block";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reset all tracking state (called on session reset). */
|
|
69
|
+
reset(): void {
|
|
70
|
+
this.strikes.clear();
|
|
71
|
+
this.sessionAllowed.clear();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Violation handling utilities.
|
|
6
|
+
*
|
|
7
|
+
* Discipline gate for tool_call blocking decisions and violation warning formatting.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ViolationType } from "../shared/workflow-types.js";
|
|
11
|
+
import type { Violation } from "../state/feature-session.js";
|
|
12
|
+
import { describeTddViolation } from "./tdd-enforcement.js";
|
|
13
|
+
|
|
14
|
+
export const UNRECOVERABLE_TYPES: ReadonlySet<ViolationType> = new Set(["phase-write-restriction", "tdd-write-order"]);
|
|
15
|
+
|
|
16
|
+
export interface DisciplineGateOpts {
|
|
17
|
+
discipline: "off" | "advisory" | "strict";
|
|
18
|
+
blockReason: string;
|
|
19
|
+
warning: string;
|
|
20
|
+
pendingMap: Map<string, string>;
|
|
21
|
+
toolCallId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Apply discipline gate to a tool_call event.
|
|
25
|
+
*
|
|
26
|
+
* - "strict" mode: always hard-blocks (identical whether the session is interactive,
|
|
27
|
+
* a subagent, or headless — a strict gate must not be bypassable by running in a
|
|
28
|
+
* different session kind)
|
|
29
|
+
* - "advisory" mode: always stores warning (non-blocking)
|
|
30
|
+
* - "off" mode: skip entirely
|
|
31
|
+
*
|
|
32
|
+
* Returns `{ block: true, reason }` if the call should be blocked, null otherwise.
|
|
33
|
+
*/
|
|
34
|
+
export function applyDisciplineGate(opts: DisciplineGateOpts): { block: true; reason: string } | null {
|
|
35
|
+
if (opts.discipline === "strict") {
|
|
36
|
+
return { block: true, reason: opts.blockReason };
|
|
37
|
+
}
|
|
38
|
+
if (opts.discipline === "advisory") {
|
|
39
|
+
opts.pendingMap.set(opts.toolCallId, opts.warning);
|
|
40
|
+
}
|
|
41
|
+
// "off": skip entirely
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatViolationWarning(violation: Violation): string {
|
|
46
|
+
return describeTddViolation(violation);
|
|
47
|
+
}
|