Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .plannotator/tripwires.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"rules": [
{
"id": "annotation-pipeline",
"globs": ["packages/shared/external-annotation.ts"],
"note": "External annotation pipeline — agent findings, tour stops, and tripwires all flow through here. Changes affect every runtime."
},
{
"id": "review-server-core",
"globs": ["packages/server/review.ts", "apps/pi-extension/server/serverReview.ts"],
"symbols": ["resolveAgentCwd", "waitForDecision"],
"note": "Review server lifecycle — cwd resolution and the decision promise are load-bearing for every integration."
},
{
"id": "pi-vendoring",
"globs": ["apps/pi-extension/vendor.sh"],
"note": "Vendor list controls which shared modules reach Pi. A miss here silently breaks runtime parity."
},
{
"id": "feedback-submission",
"globs": ["packages/review-editor/App.tsx"],
"symbols": ["exportReviewFeedback", "reviewerAnnotations"],
"note": "Feedback submission path — what reviewers send back to the agent. Double-check exclusion filters."
}
]
}
9 changes: 9 additions & 0 deletions apps/copilot/commands/plannotator-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@ allowed-tools: shell(plannotator:*)
## Your task

If the review above contains feedback or annotations, address them. If no changes were requested, acknowledge and continue.

## Tripwires (slop-free zones)

Two extra flags drive tripwires non-interactively (no review UI opens), with their output captured above:

- `plannotator review --tripwires` (or `-t`) prints a scan report for the current diff — which configured slop-free zones the changes trip, plus the global and repo rule tables. Read it and address any tripped zones.
- `plannotator review --add-tripwire <glob>` returns an instruction and a JSON rule snippet to apply. It does NOT write the file — apply the returned snippet yourself.

Rules merge two layers: a private, auto-created global file at `~/.plannotator/tripwires/<project-key>.json` (never committed) plus an optional committed `.plannotator/tripwires.json` at the repo root.
6 changes: 5 additions & 1 deletion apps/hook/server/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ describe("CLI top-level help", () => {
expect(output).toContain("plannotator --help");
expect(output).toContain("plannotator --version, -v");
expect(output).toContain("plannotator [--browser <name>]");
expect(output).toContain("plannotator review [--git] [PR_URL]");
expect(output).toContain("plannotator review [--git] [--tripwires|-t] [--add-tripwire <description...>] [PR_URL]");
expect(output).toContain("plannotator tripwires <list|add|validate|path>");
expect(output).toContain("plannotator tripwires add");
expect(output).toContain("--tripwires");
expect(output).toContain("--add-tripwire");
expect(output).toContain("plannotator annotate <file.md | file.html | https://... | folder/>");
expect(output).toContain("plannotator annotate-last [--stdin]");
expect(output).toContain("plannotator setup-goal <interview|facts>");
Expand Down
5 changes: 4 additions & 1 deletion apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export function formatTopLevelHelp(): string {
" plannotator --help",
" plannotator --version, -v",
" plannotator [--browser <name>]",
" plannotator review [--git] [PR_URL]",
" plannotator review [--git] [--tripwires|-t] [--add-tripwire <description...>] [PR_URL]",
" plannotator tripwires <list|add|validate|path>",
" plannotator tripwires add <description...> | --glob <g> [--symbol <s>] [--note <text>] [--repo]",
" plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina] [--gate] [--json] [--hook]",
" plannotator annotate-last [--stdin] [--gate] [--json] [--hook]",
" plannotator setup-goal <interview|facts> <bundle.json | -> [--json]",
Expand All @@ -46,6 +48,7 @@ export function formatInteractiveNoArgClarification(): string {
"",
"For interactive use, try:",
" plannotator review",
" plannotator tripwires list",
" plannotator annotate <file.md | file.html | https://...>",
" plannotator setup-goal interview bundle.json --json",
" plannotator last",
Expand Down
267 changes: 267 additions & 0 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ import {
import { type DiffType, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import {
evaluateTripwires,
formatTripwiresMarkdown,
buildAddTripwirePrompt,
appendRuleToTripwiresJson,
parseTripwiresConfigDetailed,
} from "@plannotator/shared/tripwires";
import {
resolveMergedTripwires,
globalTripwiresPath,
readGlobalTripwiresFile,
readTripwiresFile,
repoRootFromCwd,
writeGlobalTripwiresFile,
writeRepoTripwiresFile,
} from "@plannotator/server/tripwires";
import {
normalizeGoalSetupBundle,
type GoalSetupStage,
Expand Down Expand Up @@ -490,6 +506,178 @@ if (args[0] === "sessions") {
}
process.exit(0);

} else if (args[0] === "tripwires") {
// ============================================
// TRIPWIRES MODE (list | validate | path)
// ============================================
//
// Inspect the two-layer slop-free-zone config: a global per-project file
// under <dataDir>/tripwires/<key>.json plus the repo-local
// .plannotator/tripwires.json. All subcommands are read-only and fail-open.

const sub = args[1];

if (sub === "list") {
// Resolve key + root (ensures the global file exists), then re-read each
// layer separately so the report can group global vs. repo rules.
const { key, root } = await resolveMergedTripwires(process.cwd());
const globalPath = key ? globalTripwiresPath(key) : "(unknown)";
const globalRules = parseTripwiresConfigDetailed(
key ? readGlobalTripwiresFile(key) : null,
).config.rules;
const repoPath = root ? `${root}/.plannotator/tripwires.json` : null;
const repoRules = root
? parseTripwiresConfigDetailed(readTripwiresFile(root)).config.rules
: [];

console.log(
formatTripwiresMarkdown({
globalKey: key,
globalPath,
repoPath,
globalRules,
repoRules,
}),
);
process.exit(0);
}

if (sub === "validate") {
const { key, root } = await resolveMergedTripwires(process.cwd());
const globalPath = key ? globalTripwiresPath(key) : "(unknown)";
const repoPath = root ? `${root}/.plannotator/tripwires.json` : null;

const globalResult = parseTripwiresConfigDetailed(
key ? readGlobalTripwiresFile(key) : null,
);
const repoResult = parseTripwiresConfigDetailed(
root ? readTripwiresFile(root) : null,
);

let hasError = false;
const report = (label: string, path: string, result: ReturnType<typeof parseTripwiresConfigDetailed>) => {
console.log(`${label} (${path})`);
if (result.diagnostics.length === 0) {
console.log(` ${result.config.rules.length} rule(s), no problems`);
} else {
for (const d of result.diagnostics) {
if (d.level === "error") hasError = true;
const where = typeof d.ruleIndex === "number" ? ` [rule ${d.ruleIndex}]` : "";
console.log(` ${d.level}: ${d.message}${where}`);
}
}
};

report("Global", globalPath, globalResult);
report("Repo", repoPath ?? ".plannotator/tripwires.json", repoResult);
process.exit(hasError ? 1 : 0);
}

if (sub === "path") {
const root = await repoRootFromCwd(process.cwd());
const { key } = await resolveMergedTripwires(process.cwd());
if (key) console.log(globalTripwiresPath(key));
console.log(root ? `${root}/.plannotator/tripwires.json` : ".plannotator/tripwires.json");
process.exit(0);
}

if (sub === "add") {
// Two modes sharing one verb:
// structured flags (--glob ...) → write the rule directly
// free text → print the agent instruction (no write)
const globs: string[] = [];
const symbols: string[] = [];
let note: string | undefined;
let id: string | undefined;
let toRepo = false;
const descriptionParts: string[] = [];

const rest = args.slice(2);
for (let i = 0; i < rest.length; i++) {
const token = rest[i];
if (token === "--glob" || token === "-g") {
if (rest[i + 1]) globs.push(rest[++i]);
} else if (token === "--symbol" || token === "-s") {
if (rest[i + 1]) symbols.push(rest[++i]);
} else if (token === "--note") {
if (rest[i + 1]) note = rest[++i];
} else if (token === "--id") {
if (rest[i + 1]) id = rest[++i];
} else if (token === "--repo") {
toRepo = true;
} else {
descriptionParts.push(token);
}
}

if (globs.length > 0) {
// Direct write. Explicit user action → errors are reported, not
// swallowed (unlike review-time fail-open reads).
const { root, key } = await resolveMergedTripwires(process.cwd());
let targetPath: string;
let raw: string | null;
if (toRepo) {
if (!root) {
console.error("tripwires add --repo requires running inside a git repository.");
process.exit(1);
}
targetPath = `${root}/.plannotator/tripwires.json`;
raw = readTripwiresFile(root);
} else {
if (!key) {
console.error("Could not derive a project key (not inside a git repository?). Run from the repo, or use --repo inside one.");
process.exit(1);
}
targetPath = globalTripwiresPath(key);
raw = readGlobalTripwiresFile(key);
}

const result = appendRuleToTripwiresJson(raw, { globs, symbols, note, id });
if (!result.ok) {
console.error(`Cannot add rule: ${result.error}`);
process.exit(1);
}
try {
if (toRepo) {
writeRepoTripwiresFile(root!, result.json);
} else {
writeGlobalTripwiresFile(key!, result.json);
}
} catch (err) {
console.error(`Failed to write ${targetPath}:`, err instanceof Error ? err.message : err);
process.exit(1);
}

console.log(`Added tripwire "${result.rule.id}" to ${toRepo ? "repo" : "global"} config:`);
console.log(` ${targetPath}`);
console.log("");
console.log(JSON.stringify(result.rule, null, 2));
console.log("");
console.log("Run `plannotator tripwires list` to see all rules.");
process.exit(0);
}

const description = descriptionParts.join(" ").trim();
if (description) {
const { root, key } = await resolveMergedTripwires(process.cwd());
console.log(
buildAddTripwirePrompt({
description,
globalPath: key ? globalTripwiresPath(key) : undefined,
repoPath: root ? `${root}/.plannotator/tripwires.json` : undefined,
}),
);
process.exit(0);
}

console.error("Usage: plannotator tripwires add <description...>");
console.error(" plannotator tripwires add --glob <glob> [--glob ...] [--symbol <s> ...] [--note <text>] [--id <id>] [--repo]");
process.exit(1);
}

console.error("Usage: plannotator tripwires <list|add|validate|path>");
process.exit(1);

} else if (args[0] === "review") {
// ============================================
// CODE REVIEW MODE
Expand All @@ -510,6 +698,45 @@ if (args[0] === "sessions") {
let worktreePool: WorktreePool | undefined;
let worktreeCleanup: (() => void | Promise<void>) | undefined;

// Non-interactive tripwire flags short-circuit as soon as rawPatch is known —
// BEFORE the --local PR checkout (worktree/clone) so a PR scan never pays for a
// checkout it discards, and BEFORE detectProjectName/server startup. The scan
// only needs rawPatch. Keyed off the launch repo (no agentCwd yet → cwd falls
// through to gitContext?.cwd / process.cwd()), matching Pi and OpenCode.
const maybeRunTripwireShortCircuit = async (): Promise<void> => {
if (reviewArgs.tripwires) {
const tripwireCwd = gitContext?.cwd ?? process.cwd();
const { config, root, key } = await resolveMergedTripwires(tripwireCwd);
const hits = evaluateTripwires(rawPatch, config, { cwd: root ?? undefined });
const globalPath = key ? globalTripwiresPath(key) : "(unknown)";
const globalRules = parseTripwiresConfigDetailed(
key ? readGlobalTripwiresFile(key) : null,
).config.rules;
const repoPath = root ? `${root}/.plannotator/tripwires.json` : null;
const repoRules = root
? parseTripwiresConfigDetailed(readTripwiresFile(root)).config.rules
: [];
console.log(
formatTripwiresMarkdown({ globalKey: key, globalPath, repoPath, globalRules, repoRules, hits }),
);
process.exit(0);
}
if (reviewArgs.addTripwire) {
// The description is natural language; resolve real paths so the
// emitted instruction points the agent at the exact files.
const tripwireCwd = gitContext?.cwd ?? process.cwd();
const { root, key } = await resolveMergedTripwires(tripwireCwd);
console.log(
buildAddTripwirePrompt({
description: reviewArgs.addTripwire,
globalPath: key ? globalTripwiresPath(key) : undefined,
repoPath: root ? `${root}/.plannotator/tripwires.json` : undefined,
}),
);
process.exit(0);
}
};

if (isPRMode) {
// --- PR Review Mode ---
const prRef = parsePRUrl(urlArg);
Expand Down Expand Up @@ -548,6 +775,9 @@ if (args[0] === "sessions") {
process.exit(1);
}

// Scan with the PR rawPatch and exit before any --local checkout work.
await maybeRunTripwireShortCircuit();

// --local: create a local checkout with the PR head for full file access
if (useLocal && prMetadata) {
// Hoisted so catch block can clean up partially-created directories
Expand Down Expand Up @@ -709,6 +939,9 @@ if (args[0] === "sessions") {
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;

// Local mode: rawPatch is ready, scan and exit before server startup.
await maybeRunTripwireShortCircuit();
}

const reviewProject = (await detectProjectName()) ?? "_unknown";
Expand Down Expand Up @@ -1317,6 +1550,40 @@ if (args[0] === "sessions") {
diffError = diffResult.error;
}

// Non-interactive tripwire flags short-circuit BEFORE server startup. Emits
// {decision:"annotated", feedback, isPRMode:true} — the bridge returns
// feedback verbatim for isPRMode:true (no deny suffix), so no bridge change.
if (reviewArgs.tripwires) {
const tripwireCwd = gitContext?.cwd ?? process.env.PLANNOTATOR_CWD ?? process.cwd();
const { config, root, key } = await resolveMergedTripwires(tripwireCwd);
const hits = evaluateTripwires(rawPatch, config, { cwd: root ?? undefined });
const globalPath = key ? globalTripwiresPath(key) : "(unknown)";
const globalRules = parseTripwiresConfigDetailed(
key ? readGlobalTripwiresFile(key) : null,
).config.rules;
const repoPath = root ? `${root}/.plannotator/tripwires.json` : null;
const repoRules = root
? parseTripwiresConfigDetailed(readTripwiresFile(root)).config.rules
: [];
const report = formatTripwiresMarkdown({ globalKey: key, globalPath, repoPath, globalRules, repoRules, hits });
console.log(JSON.stringify({ decision: "annotated", feedback: report, isPRMode: true }));
process.exit(0);
}
if (reviewArgs.addTripwire) {
const tripwireCwd = gitContext?.cwd ?? process.env.PLANNOTATOR_CWD ?? process.cwd();
const { root, key } = await resolveMergedTripwires(tripwireCwd);
console.log(JSON.stringify({
decision: "annotated",
feedback: buildAddTripwirePrompt({
description: reviewArgs.addTripwire,
globalPath: key ? globalTripwiresPath(key) : undefined,
repoPath: root ? `${root}/.plannotator/tripwires.json` : undefined,
}),
isPRMode: true,
}));
process.exit(0);
}

const bridgeSharingEnabled = getBridgeSharingEnabled(input);
const bridgeShareBaseUrl = getBridgeShareBaseUrl(input);
const reviewProject = (await detectProjectName()) ?? "_unknown";
Expand Down
7 changes: 7 additions & 0 deletions apps/kiro-cli/skills/plannotator-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ You may append an optional PR URL:
```bash
PLANNOTATOR_ORIGIN=kiro-cli plannotator review <pr-url>
```

You may also append the tripwire flags, whose stdout is captured back to you:
`--tripwires` (or `-t`) prints a slop-free-zone scan report for the current diff
(no UI opens), and `--add-tripwire <glob>` returns a snippet to apply (it does
not write the file). Tripwire rules merge a private, auto-created global file at
`~/.plannotator/tripwires/<project-key>.json` with an optional committed
`.plannotator/tripwires.json` at the repo root.
Loading
Loading