Skip to content
Merged
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
6 changes: 4 additions & 2 deletions claude-plugins/atag/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ skills/atag/scripts/atag-poll.sh --debug --dir /path/to/notes '@agento, @pi'
Pass regular Claude CLI args after `--`:

```bash
skills/atag/scripts/atag-poll.sh --dir /path/to/notes -- --max-budget-usd 1
skills/atag/scripts/atag-poll.sh --dir /path/to/notes -- --effort medium --max-budget-usd 1
skills/atag/scripts/atag-poll.sh --dir /path/to/notes -- --model sonnet --effort low --max-budget-usd 1
skills/atag/scripts/atag-poll.sh --dir /path/to/notes -- --model haiku --effort low --max-budget-usd 1
```

Use `--response-style terminal` or `--response-style markdown` to force Claude's final output style. The default `auto` uses terminal plain text for interactive terminals and Markdown for piped/redirected callers.
The poller temporarily defaults to `--model opus --effort low` for mechanical tag sweeps based on the 2026-05-28 interim ADR; pass Claude CLI args after `--` to override them. Use `--response-style terminal` or `--response-style markdown` to force Claude's final output style. The default `auto` uses terminal plain text for interactive terminals and Markdown for piped/redirected callers.

## Obsidian styling (optional)

Expand Down
18 changes: 13 additions & 5 deletions claude-plugins/atag/skills/atag/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ End every agent reply with `<!--atag:eot-->` so cheap pollers can skip threads w

## If further human input required

When the tag is ambiguous, missing context, or non-actionable, **don't guess**. Wrap with `[!NOTE]+`, keep the original tag as the first body line, and add a clarifying question:
When the tag is ambiguous, missing context, or non-actionable, **don't guess**. Do not invent facts, benefits, metrics, names, dates, or other specifics that are not present in the document. If the human asks you to make something concrete but the concrete detail is missing, wrap with `[!NOTE]+`, keep the original tag as the first body line, and ask for the missing detail:

```
> [!NOTE]+ awaiting clarification
Expand Down Expand Up @@ -211,10 +211,15 @@ skills/atag/scripts/atag-poll.sh --dir /path/to/notes
Defaults:

- Polls every 60 seconds until the terminal closes or you press `Ctrl-C`.
- Prints one startup line: `Watching for @agent, @claude, @codex agent tags in /path/to/notes...`
- Prints one timestamped startup line: `[HH:MM] Watching for @agent, @claude, @codex agent tags in /path/to/notes...`
- Prints nothing on no-match unless `--debug` is set.
- With `--debug`, no-match prints: `[HH:MM] No @agent, @claude, @codex agent tags detected`.
- Runs Claude from the target directory with `claude -p --model sonnet --permission-mode acceptEdits`.
- When a match is found, prints a compact status line like `[HH:MM] atag-poll: found 1 agent tag match (@agent, @claude, @codex) in notes/file.md`.
- In regular mode, Claude startup prints `[HH:MM] atag-poll: spawning claude agent to resolve...`.
- In `--debug`, the full Claude command/prompt line is also printed with a `[DEBUG]` prefix, and a heartbeat prints every 15 seconds while Claude is still running.
- On `Ctrl-C`, exits with a one-line interruption warning; if Claude had started editing, the markdown file may be partially updated.
- Runs Claude from the target directory with `claude -p --model opus --permission-mode acceptEdits --effort low`. This is an interim default from [ADR 2026-05-28](../../docs/adrs/2026-05-28-atag-poller-opus-low-default.md); pass Claude CLI args after `--` to override the default model or effort.
- Defaults `--response-style auto`: terminal stdout requests plain terminal text; piped/redirected/UI callers get Markdown. Use `--response-style terminal` or `--response-style markdown` to force it.
- Resolves the human speaker label from `--name`/`--user-name`, then `git config user.name`, GitHub user name, Unix username, and finally a non-colliding generic label, usually `user`.
- Uses a 30-minute timeout around Claude as a runaway guard.

Expand All @@ -226,8 +231,11 @@ Useful options:
skills/atag/scripts/atag-poll.sh --once --dir /path/to/notes
skills/atag/scripts/atag-poll.sh --name Sam --dir /path/to/notes
skills/atag/scripts/atag-poll.sh --debug --interval 30 --dir /path/to/notes
skills/atag/scripts/atag-poll.sh --response-style terminal --dir /path/to/notes
skills/atag/scripts/atag-poll.sh --dir /path/to/notes @pi
skills/atag/scripts/atag-poll.sh --dir /path/to/notes '@agento, @pi' -- --max-budget-usd 1
skills/atag/scripts/atag-poll.sh --dir /path/to/notes '@agento, @pi' -- --effort medium --max-budget-usd 1
skills/atag/scripts/atag-poll.sh --dir /path/to/notes -- --model sonnet --effort low --max-budget-usd 1
skills/atag/scripts/atag-poll.sh --dir /path/to/notes -- --model haiku --effort low --max-budget-usd 1
```

Custom triggers replace the defaults. Passing `@pi` scans for `@pi`, not `@agent`, `@claude`, or `@codex`.
Expand Down Expand Up @@ -260,7 +268,7 @@ Do not summarize already sealed `[!DONE]-` threads, skipped false positives, or

**Don't prematurely limit results.** Actionable threads cluster in recently-touched files — sort matches by file mtime descending. If you must, cap after sorting.

**Callout is for discussion, not the work.** Edits go in the **document body**; the callout is a side thread for discussion and one-line acknowledgements of the changes made. Don't paste rewritten paragraphs, drafted sections, or new code into the reply — that belongs in the body. Discussion-only tags (e.g. `@claude why did we pick X?`) have no body edit, so the answer is the reply.
**Callout is for discussion, not the work.** If the request asks for content — a paragraph, headline, checklist, code block, or YAML-shaped joke — put it in the **document body**. Use the callout only for discussion or a one-line acknowledgement.

**Proactively correct formatting.** Allow the human to write shorthand imperfectly, and normalize speaker labels to the inline-code no-colon format when you touch a thread.

Expand Down
100 changes: 83 additions & 17 deletions claude-plugins/atag/skills/atag/reference/atag-poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("atag-poll", () => {
const result = runPoll(["--once", "--dir", fixtureDir]);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(`Watching for @agent, @claude, @codex agent tags in ${realpathSync(fixtureDir)}...\n\n`);
expectStdoutWithStartup(result.stdout, "@agent, @claude, @codex");
expect(result.stderr).toBe("");
expect(await readLog()).toBe("");
});
Expand All @@ -48,7 +48,7 @@ describe("atag-poll", () => {
expect(result.stdout).toMatch(
new RegExp(
[
`^Watching for @agent, @claude, @codex agent tags in ${escapeRegExp(realpathSync(fixtureDir))}\\.\\.\\.`,
`^\\[[0-9]{2}:[0-9]{2}\\] Watching for @agent, @claude, @codex agent tags in ${escapeRegExp(realpathSync(fixtureDir))}\\.\\.\\.`,
"",
"\\[[0-9]{2}:[0-9]{2}\\] No @agent, @claude, @codex agent tags detected",
"$",
Expand All @@ -66,14 +66,16 @@ describe("atag-poll", () => {
const result = runPoll(["--once", "--dir", fixtureDir]);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(`Watching for @agent, @claude, @codex agent tags in ${realpathSync(fixtureDir)}...\n\nclaude output\n`);
expectStdoutWithStartup(result.stdout, "@agent, @claude, @codex", "claude output\n");
const log = await readLog();
expect(log).toContain(`cwd=${realpathSync(fixtureDir)}`);
expect(log).toContain("arg=-p");
expect(log).toContain("arg=--model");
expect(log).toContain("arg=sonnet");
expect(log).toContain("arg=opus");
expect(log).toContain("arg=--permission-mode");
expect(log).toContain("arg=acceptEdits");
expect(log).toContain("arg=--effort");
expect(log).toContain("arg=low");
expect(log).toContain("Use the atag skill");
});

Expand Down Expand Up @@ -454,26 +456,72 @@ describe("atag-poll", () => {
expect(await readLog()).toBe("");
});

it("separates debug match and invocation output with blank lines", async () => {
it("adds terminal response-style instructions when requested", async () => {
await installClaudeStub();
await writeFile(join(fixtureDir, "note.md"), "@codex please help\n");

const result = runPoll(["--once", "--dir", fixtureDir, "--response-style", "terminal"]);

expect(result.exitCode).toBe(0);
expect(await readLog()).toContain("Response style: terminal plain text");
});

it("adds markdown response-style instructions when requested", async () => {
await installClaudeStub();
await writeFile(join(fixtureDir, "note.md"), "@codex please help\n");

const result = runPoll(["--once", "--dir", fixtureDir, "--response-style", "markdown"]);

expect(result.exitCode).toBe(0);
expect(await readLog()).toContain("Response style: Markdown");
});

it("prints compact debug match output and hides the full prompt behind a DEBUG prefix", async () => {
await installClaudeStub({ stdout: "claude output\n" });
await writeFile(join(fixtureDir, "note.md"), "@codex please help\n");

const result = runPoll(["--once", "--debug", "--dir", fixtureDir]);

expect(result.exitCode).toBe(0);
expect(result.stderr).toContain(`for @agent, @claude, @codex\n\natag-poll: match ${realpathSync(fixtureDir)}/note.md\n`);
expect(result.stderr).toContain("\natag-poll: invoking claude ");
expect(result.stderr).toContain(`\n[`);
expect(result.stderr).toContain(`] atag-poll: found 1 agent tag match (@agent, @claude, @codex) in fixture/note.md\n`);
expect(result.stderr).toContain("] atag-poll: spawning claude agent to resolve...\n");
expect(result.stderr).toContain("] [DEBUG] atag-poll: invoking claude ");
expect(result.stderr).toEndWith("\n\n");
});

it("prints debug heartbeats while Claude is still running", async () => {
await installClaudeStub({ stdout: "slow output\n", sleepSeconds: 2 });
await writeFile(join(fixtureDir, "note.md"), "@codex please help\n");

const result = runPoll(["--once", "--debug", "--dir", fixtureDir], {
ATAG_POLL_HEARTBEAT_SECONDS: "1",
});

expect(result.exitCode).toBe(0);
expect(result.stderr).toMatch(/\[DEBUG\] atag-poll: claude still running \([0-9]+s elapsed\)/);
expect(result.stdout).toContain("slow output\n");
});

it("times out the Claude subprocess", async () => {
await installClaudeStub({ sleepSeconds: 10 });
await writeFile(join(fixtureDir, "note.md"), "@codex please help\n");

const result = runPoll(["--once", "--dir", fixtureDir, "--timeout", "1"]);

expect(result.exitCode).toBe(124);
expect(result.stderr).toContain("atag-poll: claude timed out after 1s");
expect(await readLog()).toContain("arg=opus");
});

it("lets custom triggers replace the default triggers", async () => {
await installClaudeStub();
await writeFile(join(fixtureDir, "note.md"), "@codex default only\n");

const result = runPoll(["--once", "--dir", fixtureDir, "@pi"]);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(`Watching for @pi agent tags in ${realpathSync(fixtureDir)}...\n\n`);
expectStdoutWithStartup(result.stdout, "@pi");
expect(result.stderr).toBe("");
expect(await readLog()).toBe("");
});
Expand All @@ -485,7 +533,7 @@ describe("atag-poll", () => {
const result = runPoll(["--once", "--dir", fixtureDir, "@agento,", "@pi"]);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(`Watching for @agento, @pi agent tags in ${realpathSync(fixtureDir)}...\n\nmatched\n`);
expectStdoutWithStartup(result.stdout, "@agento, @pi", "matched\n");
expect(await readLog()).toContain("@agento, @pi");
});

Expand Down Expand Up @@ -517,21 +565,23 @@ describe("atag-poll", () => {
const result = runPoll(["--once", "--dir", fixtureDir]);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(`Watching for @agent, @claude, @codex agent tags in ${realpathSync(fixtureDir)}...\n\ndone scan\n`);
expectStdoutWithStartup(result.stdout, "@agent, @claude, @codex", "done scan\n");
expect(await readLog()).toContain("note.md");
});

it("passes through Claude args after -- and lets them override defaults", async () => {
it("passes through Claude args and lets callers override default model and effort", async () => {
await installClaudeStub();
await writeFile(join(fixtureDir, "note.md"), "@codex please help\n");

const result = runPoll(["--once", "--dir", fixtureDir, "--", "--model", "opus", "--max-budget-usd", "1"]);
const result = runPoll(["--once", "--dir", fixtureDir, "--", "--model", "haiku", "--effort", "low", "--max-budget-usd", "1"]);

expect(result.exitCode).toBe(0);
const log = await readLog();
expect(log).toContain("arg=--max-budget-usd");
expect(log).toContain("arg=1");
expect(log).toContain("arg=opus");
expect(log).toContain("arg=haiku");
expect(log).toContain("arg=low");
expect(log).not.toContain("arg=opus");
});

it("propagates Claude failures", async () => {
Expand All @@ -541,18 +591,20 @@ describe("atag-poll", () => {
const result = runPoll(["--once", "--dir", fixtureDir]);

expect(result.exitCode).toBe(7);
expect(result.stdout).toBe(`Watching for @agent, @claude, @codex agent tags in ${realpathSync(fixtureDir)}...\n\npartial\n`);
expect(result.stderr).toBe("boom\n");
expectStdoutWithStartup(result.stdout, "@agent, @claude, @codex", "partial\n");
expect(result.stderr).toContain("atag-poll: spawning claude agent to resolve...");
expect(result.stderr).toContain("boom\n");
});
});

function runPoll(args: string[]) {
function runPoll(args: string[], env: Record<string, string> = {}) {
const proc = Bun.spawnSync({
cmd: [SCRIPT, ...args],
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
ATAG_POLL_LOG: logPath,
...env,
},
stdout: "pipe",
stderr: "pipe",
Expand All @@ -564,16 +616,18 @@ function runPoll(args: string[]) {
};
}

async function installClaudeStub(options: { stdout?: string; stderr?: string; exitCode?: number } = {}) {
async function installClaudeStub(options: { stdout?: string; stderr?: string; exitCode?: number; sleepSeconds?: number } = {}) {
const stdout = JSON.stringify(options.stdout ?? "");
const stderr = JSON.stringify(options.stderr ?? "");
const exitCode = options.exitCode ?? 0;
const sleepSeconds = options.sleepSeconds ?? 0;
const stub = `#!/usr/bin/env bash
set -euo pipefail
{
printf 'cwd=%s\\n' "$PWD"
for arg in "$@"; do printf 'arg=%s\\n' "$arg"; done
} >> "$ATAG_POLL_LOG"
sleep ${sleepSeconds}
printf '%b' ${stdout}
printf '%b' ${stderr} >&2
exit ${exitCode}
Expand Down Expand Up @@ -624,3 +678,15 @@ async function readLog(): Promise<string> {
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function expectStdoutWithStartup(stdout: string, triggers: string, suffix = "") {
expect(stdout).toMatch(
new RegExp(
[
`^\\[[0-9]{2}:[0-9]{2}\\] Watching for ${escapeRegExp(triggers)} agent tags in ${escapeRegExp(realpathSync(fixtureDir))}\\.\\.\\.`,
"",
escapeRegExp(suffix) + "$",
].join("\n"),
),
);
}
Loading
Loading