diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5b27305..28b8d93 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "watchdog", "description": "Self-referential loop for Claude Code that re-feeds the user's prompt until the task truly stops producing file edits. Uses a headless Haiku classifier to judge convergence, requires the agent to actually call tools before exit (no pure-text 'done' claims), and is hidden from the agent so it cannot cheat. Apache 2.0, derived from ralph-loop.", - "version": "1.2.3", + "version": "1.2.4", "author": { "name": "Jonyan Dunh", "email": "jonyandunh@outlook.com" @@ -30,5 +30,5 @@ ] } ], - "version": "1.2.3" + "version": "1.2.4" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 763baaf..d39e36b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "watchdog", - "version": "1.2.3", + "version": "1.2.4", "description": "Self-referential loop for Claude Code. Re-feeds the same prompt after every turn until files actually stop changing.", "author": { "name": "Jonyan Dunh", diff --git a/README.es.md b/README.es.md index 5169dd6..e92875d 100644 --- a/README.es.md +++ b/README.es.md @@ -117,6 +117,18 @@ Si alguna de las dos falla, el bucle continúa. Rutas de salida adicionales: | `/watchdog:stop` | Cancela el watchdog de la sesión actual | `/watchdog:stop` | | `/watchdog:help` | Imprime la referencia completa dentro de Claude Code | `/watchdog:help` | +### Prompts largos desde un archivo + +Si tu prompt contiene saltos de línea, comillas, backticks, `$` u otros caracteres que romperían el análisis de argumentos del shell dentro del bloque `!` del slash command — por ejemplo una especificación de tarea en Markdown con varios párrafos — pásalo como un archivo: + +```bash +/watchdog:start --prompt-file ./tmp/my-task.md --max-iterations 20 +``` + +El archivo lo lee Node directamente con `fs.readFileSync`, sin pasar por el escape del shell. Las rutas relativas se resuelven respecto al directorio de trabajo actual de la sesión de Claude Code. El BOM UTF-8 se elimina automáticamente (los archivos del Bloc de notas de Windows son seguros), el contenido CRLF se conserva byte a byte, y los espacios al inicio/final se recortan. **No se puede combinar con un `` inline** — elige uno u otro. + +Funciona con rutas POSIX en Linux/macOS/WSL (`/home/tu/…`, `./tmp/…`), rutas absolutas de Windows (`C:\Users\tu\…`, `C:/Users/tu/…`) y rutas UNC (`\\server\share\…`). El `~` lo expande tu shell (bash/zsh), no Watchdog — en `cmd.exe` usa `%USERPROFILE%\…` o una ruta absoluta. Las rutas con espacios deben ir entre comillas como cualquier otro argumento del shell: `--prompt-file "./my prompts/task.md"`. Para la referencia completa del manejo de rutas, consulta `/watchdog:help`. + --- ## Archivo de estado @@ -414,6 +426,7 @@ Watchdog mantiene el mecanismo central — un `Stop hook` que vuelve a inyectar | **Ámbito del estado** | Un archivo de estado por cada sesión de Claude Code — sin límite de watchdogs concurrentes en el mismo proyecto | Un solo archivo de estado por proyecto — solo UN ralph-loop puede correr por proyecto a la vez | | **Formato del archivo de estado** | JSON (parseado con `JSON.parse` nativo) | Markdown con frontmatter YAML (parseado con sed/awk/grep) | | **Runtime** | Node.js 18+ | Bash + jq + POSIX coreutils | +| **Entrada del prompt** | Inline vía `$ARGUMENTS`, **o** `--prompt-file ` — lee el archivo directamente con `fs.readFileSync` de Node, **saltándose por completo el análisis de argumentos del shell**. Seguro para Markdown de varios párrafos con saltos de línea, comillas, backticks, `$`, etc. El BOM UTF-8 se elimina automáticamente; CRLF se preserva byte a byte. | Solo inline vía `$ARGUMENTS` en el bloque `!` del shell del slash command. Cualquier `"`, `` ` ``, `$` o salto de línea sin escapar en el prompt rompe el parser de `bash` con `unexpected EOF`. Sin fallback a archivo ni a stdin — las especificaciones de tareas Markdown de varios párrafos deben convertirse primero en una cadena de una sola línea segura para el shell. | Ver [`NOTICE`](./NOTICE) para la atribución completa y el listado detallado de modificaciones. diff --git a/README.ja.md b/README.ja.md index c2fd614..834562a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -118,6 +118,18 @@ _`Watchdog` は `Claude Code` のプラグインです。同一セッション | `/watchdog:stop` | 現在のセッションの watchdog をキャンセル | `/watchdog:stop` | | `/watchdog:help` | Claude Code 内で完全なリファレンスを表示 | `/watchdog:help` | +### ファイルから長い prompt を渡す + +プロンプトに改行、引用符、バッククォート、`$`、その他 slash command の `!` ブロック内でシェル引数解析を壊す文字が含まれる場合——たとえば複数段落の Markdown タスク仕様など——ファイルとして渡してください: + +```bash +/watchdog:start --prompt-file ./tmp/my-task.md --max-iterations 20 +``` + +ファイルは Node が `fs.readFileSync` で直接読み込むので、シェルのエスケープを完全に回避します。相対パスは Claude Code セッションのカレントワーキングディレクトリを基準に解決されます。UTF-8 BOM は自動的に除去され(Windows メモ帳で保存したファイルでも安全)、CRLF はバイト単位でそのまま保持され、先頭/末尾の空白はトリムされます。**インラインの `` とは併用できません**——どちらか一方を選んでください。 + +Linux/macOS/WSL の POSIX パス(`/home/you/…`、`./tmp/…`)、Windows 絶対パス(`C:\Users\you\…`、`C:/Users/you/…`)、UNC パス(`\\server\share\…`)のすべてに対応しています。`~` はシェル(bash/zsh)が展開するので、Watchdog 側では処理しません——`cmd.exe` では `%USERPROFILE%\…` か絶対パスを使ってください。スペースを含むパスは他のシェル引数と同様に引用符で囲む必要があります:`--prompt-file "./my prompts/task.md"`。パス処理の完全なリファレンスは `/watchdog:help` を参照してください。 + --- ## 状態ファイル @@ -415,6 +427,7 @@ Watchdog はコアの仕組み(prompt を再投入する Stop hook)は引き | **状態のスコープ** | Claude Code セッションごとに 1 つの状態ファイル —— 同じプロジェクトで並行 watchdog を何個でも走らせられる | プロジェクトに状態ファイルは 1 つだけ —— 1 つのプロジェクトで同時に走らせられる ralph-loop は 1 つだけ | | **状態ファイルの形式** | JSON(ネイティブの `JSON.parse` でパース) | YAML frontmatter 付き Markdown(sed/awk/grep でパース) | | **ランタイム** | Node.js 18+ —— クロスプラットフォーム(Linux、macOS、ネイティブ Windows) | Bash + jq + POSIX coreutils —— Unix 専用 | +| **prompt 入力方式** | `$ARGUMENTS` によるインライン、**または** `--prompt-file ` —— Node の `fs.readFileSync` でファイルを直接読み込み、**シェルの引数解析を完全に回避します**。複数段落の Markdown に含まれる改行、引用符、バッククォート、`$` などを安全に渡せます。UTF-8 BOM は自動的に除去され、CRLF はバイト単位でそのまま保持されます。 | slash command の `!` シェルブロック内の `$ARGUMENTS` によるインライン入力のみ。prompt にエスケープされていない `"`、`` ` ``、`$`、改行が一つでもあると `bash` の解析が `unexpected EOF` で失敗します。ファイルや stdin のフォールバックはなく、複数段落の Markdown タスク仕様はシェルで安全な 1 行文字列に変換しないと使えません。 | 完全な帰属表示と変更一覧は [`NOTICE`](./NOTICE) を参照してください。 diff --git a/README.ko.md b/README.ko.md index b0e0034..4730b95 100644 --- a/README.ko.md +++ b/README.ko.md @@ -118,6 +118,18 @@ _`Claude Code` 플러그인입니다. 하나의 세션 안에서 현재 agent를 | `/watchdog:stop` | 현재 세션의 watchdog 취소 | `/watchdog:stop` | | `/watchdog:help` | `Claude Code` 안에서 전체 레퍼런스 출력 | `/watchdog:help` | +### 파일로 긴 prompt 전달하기 + +prompt 안에 줄바꿈, 따옴표, 백틱, `$` 또는 슬래시 커맨드 `!` 블록 내부의 셸 인자 파싱을 깨뜨릴 만한 문자가 들어있다면 — 예를 들어 여러 단락짜리 Markdown 작업 명세 — 파일로 전달하세요: + +```bash +/watchdog:start --prompt-file ./tmp/my-task.md --max-iterations 20 +``` + +파일은 Node가 `fs.readFileSync`로 직접 읽기 때문에 셸 이스케이프를 완전히 우회합니다. 상대 경로는 Claude Code 세션의 현재 작업 디렉터리를 기준으로 해석됩니다. UTF-8 BOM은 자동으로 제거되며(Windows 메모장으로 저장한 파일도 안전), CRLF 내용은 바이트 단위로 보존되고, 앞뒤 공백은 잘립니다. **인라인 ``와 함께 쓸 수 없습니다** — 둘 중 하나만 고르세요. + +Linux/macOS/WSL의 POSIX 경로(`/home/you/…`, `./tmp/…`), Windows 절대 경로(`C:\Users\you\…`, `C:/Users/you/…`), UNC 경로(`\\server\share\…`)를 모두 지원합니다. `~`는 Watchdog이 아니라 셸(bash/zsh)이 확장합니다 — `cmd.exe`에서는 `%USERPROFILE%\…` 또는 절대 경로를 사용하세요. 공백이 들어간 경로는 다른 셸 인자처럼 따옴표로 감싸야 합니다: `--prompt-file "./my prompts/task.md"`. 경로 처리에 대한 전체 레퍼런스는 `/watchdog:help`를 참고하세요. + --- ## 상태 파일 @@ -415,6 +427,7 @@ Watchdog은 핵심 메커니즘(prompt를 다시 먹여주는 `Stop hook`)을 | **상태 범위** | Claude Code 세션마다 상태 파일 하나씩 — 같은 프로젝트에서 동시에 원하는 만큼 watchdog을 돌릴 수 있습니다 | 프로젝트당 상태 파일 하나 — 한 프로젝트에서 동시에 돌릴 수 있는 ralph-loop는 하나뿐입니다 | | **상태 파일 포맷** | JSON (네이티브 `JSON.parse`로 파싱) | YAML frontmatter가 있는 Markdown (sed/awk/grep으로 파싱) | | **런타임** | Node.js 18+ — 크로스 플랫폼 (Linux, macOS, 네이티브 Windows) | Bash + jq + POSIX coreutils — Unix 전용 | +| **prompt 입력 방식** | `$ARGUMENTS` 인라인, **또는** `--prompt-file ` — Node의 `fs.readFileSync`로 파일을 직접 읽어 **셸 인자 파싱을 완전히 우회합니다**. 여러 단락짜리 Markdown에 들어 있는 줄바꿈, 따옴표, 백틱, `$` 같은 문자를 안전하게 전달할 수 있습니다. UTF-8 BOM은 자동으로 제거되고 CRLF는 바이트 단위로 그대로 보존됩니다. | 슬래시 커맨드 `!` 셸 블록 안의 `$ARGUMENTS`를 통한 인라인 입력만 지원합니다. prompt에 이스케이프되지 않은 `"`, `` ` ``, `$` 또는 줄바꿈이 하나라도 있으면 `bash` 파싱이 `unexpected EOF`로 실패합니다. 파일이나 stdin 대체 경로가 없기 때문에, 여러 단락짜리 Markdown 작업 명세는 먼저 셸 안전한 한 줄 문자열로 압축해야 합니다. | 전체 출처 표기와 수정 내역 전체는 [`NOTICE`](./NOTICE)를 참고하세요. diff --git a/README.md b/README.md index 3aaa68b..bf6e6b2 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,18 @@ If either check fails, the loop continues. Additional exit paths: | `/watchdog:stop` | Cancel the watchdog in the current session | `/watchdog:stop` | | `/watchdog:help` | Print the full reference inside Claude Code | `/watchdog:help` | +### Long prompts from a file + +If your prompt contains newlines, quotes, backticks, `$`, or other characters that would break shell-argument parsing inside the slash command's `!` block — for example a multi-paragraph Markdown task spec — pass it as a file instead: + +```bash +/watchdog:start --prompt-file ./tmp/my-task.md --max-iterations 20 +``` + +The file is read directly by Node (`fs.readFileSync`), bypassing shell escaping entirely. Relative paths resolve against the Claude Code session's current working directory. UTF-8 BOM is stripped automatically (so Windows Notepad files are safe), CRLF content is preserved byte-for-byte, and leading/trailing whitespace is trimmed. Mutually exclusive with an inline `` — pick one or the other. + +Works with Linux/macOS/WSL POSIX paths (`/home/you/…`, `./tmp/…`), Windows absolute paths (`C:\Users\you\…`, `C:/Users/you/…`), and UNC paths (`\\server\share\…`). `~` is expanded by your shell (bash/zsh), not by Watchdog — on `cmd.exe` use `%USERPROFILE%\…` or an absolute path. Paths with spaces must be quoted as usual: `--prompt-file "./my prompts/task.md"`. See `/watchdog:help` for the full path-handling reference. + --- ## State File @@ -414,6 +426,7 @@ Watchdog keeps the core mechanic — a Stop hook that re-feeds the prompt — an | **State scoping** | One state file per Claude Code session — unlimited concurrent watchdogs in the same project | One state file per project — only ONE ralph-loop can run per project at a time | | **State file format** | JSON (parsed with native `JSON.parse`) | Markdown with YAML frontmatter (parsed with sed/awk/grep) | | **Runtime** | Node.js 18+ | Bash + jq + POSIX coreutils | +| **Prompt input** | Inline via `$ARGUMENTS`, **or** `--prompt-file ` — reads the file directly with Node's `fs.readFileSync`, bypassing shell argument parsing entirely. Safe for multi-paragraph Markdown containing newlines, quotes, backticks, `$`, etc. UTF-8 BOM is stripped automatically; CRLF is preserved byte-for-byte. | Inline via `$ARGUMENTS` in the slash command's `!` shell block only. Any unescaped `"`, `` ` ``, `$`, or newline in the prompt breaks `bash` parsing with `unexpected EOF`. No file or stdin fallback — multi-paragraph Markdown task specs must be mangled into a single-line, shell-safe string first. | See [`NOTICE`](./NOTICE) for the full attribution and the complete list of modifications. diff --git a/README.pt.md b/README.pt.md index 04b7e60..1e1aa1b 100644 --- a/README.pt.md +++ b/README.pt.md @@ -117,6 +117,18 @@ Se qualquer uma das duas falhar, o loop continua. Outras formas de sair: | `/watchdog:stop` | Cancela o watchdog da sessão atual | `/watchdog:stop` | | `/watchdog:help` | Mostra a referência completa dentro do `Claude Code` | `/watchdog:help` | +### Prompts longos a partir de um arquivo + +Se seu prompt contiver quebras de linha, aspas, crases, `$` ou outros caracteres que quebrariam o parser de argumentos do shell dentro do bloco `!` do slash command — por exemplo uma especificação de tarefa em Markdown com vários parágrafos — passe-o como um arquivo: + +```bash +/watchdog:start --prompt-file ./tmp/my-task.md --max-iterations 20 +``` + +O arquivo é lido diretamente pelo Node com `fs.readFileSync`, ignorando totalmente o escape do shell. Caminhos relativos são resolvidos a partir do diretório de trabalho atual da sessão do Claude Code. O BOM UTF-8 é removido automaticamente (arquivos do Bloco de Notas do Windows são seguros), o conteúdo CRLF é preservado byte a byte, e espaços em branco no início/fim são aparados. **Não pode ser combinado com um `` inline** — escolha um ou outro. + +Funciona com caminhos POSIX em Linux/macOS/WSL (`/home/voce/…`, `./tmp/…`), caminhos absolutos do Windows (`C:\Users\voce\…`, `C:/Users/voce/…`) e caminhos UNC (`\\server\share\…`). O `~` é expandido pelo seu shell (bash/zsh), não pelo Watchdog — no `cmd.exe` use `%USERPROFILE%\…` ou um caminho absoluto. Caminhos com espaços precisam ser colocados entre aspas, como qualquer outro argumento de shell: `--prompt-file "./my prompts/task.md"`. Veja `/watchdog:help` para a referência completa de tratamento de caminhos. + --- ## Arquivo de estado @@ -414,6 +426,7 @@ O Watchdog mantém a mecânica principal — um Stop hook que reinjeta o prompt | **Escopo do estado** | Um arquivo de estado por sessão do Claude Code — quantos watchdogs simultâneos quiser no mesmo projeto | Um único arquivo de estado por projeto — só UM ralph-loop roda por projeto de cada vez | | **Formato do arquivo de estado** | JSON (parseado com `JSON.parse` nativo) | Markdown com frontmatter YAML (parseado com sed/awk/grep) | | **Runtime** | Node.js 18+ | Bash + jq + POSIX coreutils | +| **Entrada do prompt** | Inline via `$ARGUMENTS`, **ou** `--prompt-file ` — lê o arquivo diretamente com `fs.readFileSync` do Node, **ignorando totalmente o parser de argumentos do shell**. Seguro para Markdown de vários parágrafos contendo quebras de linha, aspas, crases, `$`, etc. O BOM UTF-8 é removido automaticamente; CRLF é preservado byte a byte. | Apenas inline via `$ARGUMENTS` no bloco `!` do shell do slash command. Qualquer `"`, `` ` ``, `$` ou quebra de linha sem escape no prompt quebra o parser do `bash` com `unexpected EOF`. Sem fallback para arquivo ou stdin — especificações de tarefa em Markdown com vários parágrafos precisam ser convertidas antes em uma string de uma única linha segura para o shell. | Veja o [`NOTICE`](./NOTICE) pra atribuição completa e a lista total de modificações. diff --git a/README.vi.md b/README.vi.md index e8733e6..b1b3728 100644 --- a/README.vi.md +++ b/README.vi.md @@ -117,6 +117,18 @@ Chỉ cần một trong hai sai là loop tiếp tục. Các đường thoát kh | `/watchdog:stop` | Huỷ watchdog trong session hiện tại | `/watchdog:stop` | | `/watchdog:help` | In bản tham chiếu đầy đủ ngay trong Claude Code | `/watchdog:help` | +### Prompt dài từ file + +Nếu prompt của bạn chứa xuống dòng, dấu ngoặc kép, backtick, `$` hoặc các ký tự khác có thể phá vỡ việc phân tích đối số shell trong khối `!` của slash command — ví dụ một bản mô tả nhiệm vụ Markdown nhiều đoạn — hãy truyền nó dưới dạng file: + +```bash +/watchdog:start --prompt-file ./tmp/my-task.md --max-iterations 20 +``` + +File được Node đọc trực tiếp bằng `fs.readFileSync`, hoàn toàn bỏ qua escape của shell. Đường dẫn tương đối được phân giải theo thư mục làm việc hiện tại của session Claude Code. UTF-8 BOM được tự động loại bỏ (file lưu bằng Notepad trên Windows vẫn an toàn), nội dung CRLF được giữ nguyên từng byte, và khoảng trắng đầu/cuối sẽ bị cắt. **Không thể dùng cùng lúc với `` nội tuyến** — chọn một trong hai. + +Hỗ trợ đường dẫn POSIX trên Linux/macOS/WSL (`/home/you/…`, `./tmp/…`), đường dẫn tuyệt đối trên Windows (`C:\Users\you\…`, `C:/Users/you/…`) và đường dẫn UNC (`\\server\share\…`). `~` được shell (bash/zsh) mở rộng chứ không phải Watchdog — trên `cmd.exe` hãy dùng `%USERPROFILE%\…` hoặc đường dẫn tuyệt đối. Đường dẫn có dấu cách phải được đặt trong dấu nháy như mọi tham số shell khác: `--prompt-file "./my prompts/task.md"`. Xem `/watchdog:help` để có tham chiếu đầy đủ về xử lý đường dẫn. + --- ## File trạng thái @@ -414,6 +426,7 @@ Watchdog giữ nguyên cơ chế cốt lõi — một Stop hook nạp lại prom | **Phạm vi trạng thái** | Mỗi session Claude Code một file trạng thái riêng — cùng một project muốn chạy bao nhiêu watchdog song song cũng được | Cả project chỉ một file trạng thái — cùng một project tại một thời điểm chỉ chạy được MỘT ralph-loop | | **Định dạng file trạng thái** | JSON (parse bằng `JSON.parse` native) | Markdown với YAML frontmatter (parse bằng sed/awk/grep) | | **Runtime** | Node.js 18+ — đa nền tảng (Linux, macOS, Windows nguyên bản) | Bash + jq + POSIX coreutils — chỉ chạy trên Unix | +| **Cách truyền prompt** | Inline qua `$ARGUMENTS`, **hoặc** `--prompt-file ` — đọc file trực tiếp bằng `fs.readFileSync` của Node, **bỏ qua hoàn toàn việc phân tích đối số shell**. An toàn cho Markdown nhiều đoạn chứa xuống dòng, dấu ngoặc kép, backtick, `$`, v.v. UTF-8 BOM được tự động loại bỏ; CRLF được giữ nguyên từng byte. | Chỉ inline qua `$ARGUMENTS` trong khối shell `!` của slash command. Bất kỳ `"`, `` ` ``, `$` hoặc xuống dòng nào chưa được escape trong prompt đều làm `bash` báo `unexpected EOF`. Không có dự phòng bằng file hay stdin — các mô tả nhiệm vụ Markdown nhiều đoạn phải được ép thành một chuỗi một dòng an toàn với shell trước đã. | Xem [`NOTICE`](./NOTICE) để biết ghi nhận đầy đủ và danh sách thay đổi chi tiết. diff --git a/README.zh.md b/README.zh.md index 7f60d2e..0ab3d4a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -117,6 +117,18 @@ Loop 退出需要当前 turn **同时**满足两个条件: | `/watchdog:stop` | 取消当前会话的 watchdog | `/watchdog:stop` | | `/watchdog:help` | 在 Claude Code 里打印完整命令参考 | `/watchdog:help` | +### 用文件传长 prompt + +如果你的 prompt 里带换行、引号、反引号、`$` 或其它会破坏 slash command `!` 代码块里 shell 参数解析的字符——比如一整段多段落的 Markdown 任务描述——直接用文件传进去: + +```bash +/watchdog:start --prompt-file ./tmp/my-task.md --max-iterations 20 +``` + +文件由 Node 直接 `fs.readFileSync` 读取,完全绕开 shell 转义。相对路径基于 Claude Code 当前会话的工作目录解析。UTF-8 BOM 会被自动剥除(Windows 记事本保存的文件不会翻车),CRLF 换行按字节原样保留,首尾空白会被 trim。**不能和内联 `` 同时使用**,二选一。 + +支持 Linux/macOS/WSL 的 POSIX 路径(`/home/you/…`、`./tmp/…`)、Windows 绝对路径(`C:\Users\you\…`、`C:/Users/you/…`)以及 UNC 路径(`\\server\share\…`)。`~` 由你的 shell(bash/zsh)展开,Watchdog 自己不处理——在 `cmd.exe` 下请用 `%USERPROFILE%\…` 或绝对路径。带空格的路径需要像传其它 shell 参数一样加引号:`--prompt-file "./my prompts/task.md"`。完整的路径处理说明见 `/watchdog:help`。 + --- ## 状态文件 @@ -414,6 +426,7 @@ Watchdog 保留了核心机制——一个 Stop hook 重发 prompt——在此 | **状态文件作用域** | 每个 Claude Code 会话一份 state 文件 —— 同一个项目里想跑多少个并发 watchdog 都行 | 整个项目就一份 state 文件 —— 一个项目同一时间只能跑一个 ralph-loop | | **状态文件格式** | JSON(用原生 `JSON.parse` 解析) | Markdown + YAML frontmatter(用 sed/awk/grep 解析) | | **运行时** | Node.js 18+ —— 跨平台(Linux、macOS、原生 Windows) | Bash + jq + POSIX coreutils —— 只能 Unix | +| **prompt 输入方式** | 内联 `$ARGUMENTS`,**或** `--prompt-file ` —— 用 Node 的 `fs.readFileSync` 直接读文件,**完全绕开 shell 参数解析**。多段 Markdown 里的换行、引号、反引号、`$` 都能安全传入。UTF-8 BOM 自动剥除,CRLF 按字节原样保留。 | 只能通过 slash command `!` shell block 里的 `$ARGUMENTS` 内联传入。prompt 里出现任何未转义的 `"`、`` ` ``、`$` 或换行,`bash` 解析都会直接 `unexpected EOF` 挂掉。没有文件或 stdin 通道 —— 多段 Markdown 任务描述必须先手动压成一行 shell-safe 的字符串才能用。 | 完整归属和修改清单见 [`NOTICE`](./NOTICE)。 diff --git a/commands/help.md b/commands/help.md index babc4af..678d47f 100644 --- a/commands/help.md +++ b/commands/help.md @@ -38,11 +38,24 @@ Start a Watchdog in the current session. ``` /watchdog:start "Refactor services/cache.ts to use the new API. Iterate until pnpm test:cache passes." --max-iterations 20 /watchdog:start "Add tests for auth.ts until coverage hits 80%." +/watchdog:start --prompt-file ./tmp/my-big-prompt.txt --max-iterations 20 ``` **Options:** - `--max-iterations ` — safety cap, loop exits after N iterations no matter what. Recommended: 20. +- `--prompt-file ` — read the prompt from a file instead of passing it inline. Use this when your prompt contains newlines, quotes, backticks, `$`, or other characters that would break shell-argument parsing in the slash command's `!` block. Mutually exclusive with an inline positional prompt. + + **Path handling:** + + - **Linux / macOS / WSL:** POSIX absolute (`/home/you/prompts/task.txt`) or relative (`./tmp/task.txt`, `../notes.txt`) paths. Bare filenames resolve against the Claude Code session's current working directory. + - **Windows (native `cmd.exe` / PowerShell):** absolute (`C:\Users\you\prompts\task.txt` or `C:/Users/you/prompts/task.txt`), relative, and UNC (`\\server\share\task.txt`) paths all work through Node's `fs` APIs. Note: WSL files can be reached from native Windows via `\\wsl.localhost\\home\you\...` but this path is untested in CI — prefer running Claude Code *inside* WSL and using the POSIX path (`/home/you/...`). + - **Paths with spaces:** you must quote them yourself, just like any other shell argument: `--prompt-file "./my prompts/task.txt"`. + - **`~` is NOT expanded by Watchdog** — it relies on the shell. bash/zsh expand `~` to `$HOME` before Watchdog sees the arg, so `--prompt-file ~/task.txt` works there. `cmd.exe` does not expand `~`; Windows users should pass an absolute path or `%USERPROFILE%\task.txt`. + - **BOM is stripped automatically.** If you save your prompt with Windows Notepad or PowerShell's default `Set-Content`, the leading UTF-8 BOM is quietly removed so Claude doesn't see an invisible zero-width marker as the first character. + - **Line endings are preserved byte-for-byte.** CRLF files are not rewritten to LF — Claude handles both. + - **Encoding:** the file is read as UTF-8. Non-UTF-8 encodings (GBK, Shift-JIS, etc.) are not supported — convert to UTF-8 first. + - **Leading/trailing whitespace is trimmed;** interior whitespace and blank lines are preserved exactly. **Behavior:** diff --git a/commands/start.md b/commands/start.md index 9a20cd7..f4d068a 100644 --- a/commands/start.md +++ b/commands/start.md @@ -1,6 +1,6 @@ --- description: "Start Watchdog in current session" -argument-hint: "\"\" [--max-iterations N]" +argument-hint: "\"\" | --prompt-file [--max-iterations N]" allowed-tools: ["Bash(node:*)"] disable-model-invocation: true --- diff --git a/scripts/setup-watchdog.js b/scripts/setup-watchdog.js index 7663e1e..66d1b0e 100644 --- a/scripts/setup-watchdog.js +++ b/scripts/setup-watchdog.js @@ -12,6 +12,9 @@ // coreutils with a single cross-platform Node file. See NOTICE at the // repo root for the full change list. +const fs = require('fs'); +const path = require('path'); + const { error, debug } = require('../lib/log'); const { create } = require('../lib/state'); const { findClaudePid } = require('../lib/claude-pid'); @@ -19,6 +22,7 @@ const { findClaudePid } = require('../lib/claude-pid'); function parseArgs(argv) { const promptParts = []; let maxIterations = 0; + let promptFile = null; let help = false; for (let i = 0; i < argv.length; i++) { @@ -39,10 +43,64 @@ function parseArgs(argv) { i += 1; continue; } + if (token === '--prompt-file') { + const next = argv[i + 1]; + if (next === undefined) { + return { error: '--prompt-file requires a path argument' }; + } + promptFile = next; + i += 1; + continue; + } promptParts.push(token); } - return { promptParts, maxIterations, help }; + return { promptParts, maxIterations, promptFile, help }; +} + +// Read a prompt from a file, bypassing shell escaping entirely. Used when +// the prompt contains characters that would break `$ARGUMENTS` substitution +// inside the slash command's `!` block — newlines, quotes, backticks, +// `$`, etc. Returns `{ prompt }` on success or `{ error }` on failure. +// +// Path handling is delegated to path.resolve(), which is platform-aware: +// - Absolute POSIX paths (/home/…) pass through unchanged on Linux/Mac. +// - Absolute Windows paths (C:\…, C:/…, \\server\share\…) pass through +// unchanged on Windows. +// - Relative paths are resolved against process.cwd() on every platform. +// `~` is NOT expanded here — that's the shell's job, and bash/zsh already +// expand it before the args reach this script. cmd.exe users should pass +// absolute paths or use %USERPROFILE%. +function readPromptFile(promptFile) { + const resolved = path.resolve(process.cwd(), promptFile); + let contents; + try { + contents = fs.readFileSync(resolved, 'utf8'); + } catch (e) { + if (e.code === 'ENOENT') { + return { error: `prompt file not found: ${resolved}` }; + } + if (e.code === 'EISDIR') { + return { error: `--prompt-file expects a file, got a directory: ${resolved}` }; + } + if (e.code === 'EACCES' || e.code === 'EPERM') { + return { error: `permission denied reading prompt file: ${resolved}` }; + } + return { error: `failed to read prompt file ${resolved}: ${e.message}` }; + } + // Strip UTF-8 BOM. Windows tools (Notepad, PowerShell's `Set-Content` + // without `-Encoding utf8NoBOM`) frequently add U+FEFF at the start of + // UTF-8 files. `.trim()` does not remove it (BOM is not whitespace), so + // without this line the first char of the prompt Claude sees would be + // an invisible zero-width marker. + if (contents.charCodeAt(0) === 0xfeff) { + contents = contents.slice(1); + } + const prompt = contents.trim(); + if (!prompt) { + return { error: `prompt file is empty: ${resolved}` }; + } + return { prompt }; } function printHelp() { @@ -58,6 +116,7 @@ function printHelp() { '', 'Quick usage:', ' /watchdog:start "" [--max-iterations N]', + ' /watchdog:start --prompt-file [--max-iterations N]', ' /watchdog:stop', ]; for (const line of lines) process.stderr.write(`${line}\n`); @@ -76,7 +135,27 @@ function main() { process.exit(0); } - const prompt = parsed.promptParts.join(' ').trim(); + // --prompt-file and inline positional prompt are mutually exclusive. + // Supporting both would force us to invent a merge policy (prepend? + // append? error?) and every choice is surprising. Require exactly one. + if (parsed.promptFile && parsed.promptParts.length > 0) { + error('--prompt-file cannot be combined with a positional prompt'); + process.stderr.write(' Pick one: either pass the prompt inline, or use --prompt-file .\n'); + process.exit(1); + } + + let prompt; + if (parsed.promptFile) { + const result = readPromptFile(parsed.promptFile); + if (result.error) { + error(result.error); + process.exit(1); + } + prompt = result.prompt; + } else { + prompt = parsed.promptParts.join(' ').trim(); + } + if (!prompt) { error('No prompt provided'); process.stderr.write('\n'); @@ -85,7 +164,7 @@ function main() { process.stderr.write(' Examples:\n'); process.stderr.write(' /watchdog:start "Build a REST API for todos"\n'); process.stderr.write(' /watchdog:start "Fix the auth bug" --max-iterations 20\n'); - process.stderr.write(' /watchdog:start "Refactor the cache layer" --max-iterations 20\n'); + process.stderr.write(' /watchdog:start --prompt-file ./tmp/my-prompt.txt --max-iterations 20\n'); process.stderr.write('\n'); process.stderr.write(' For the full reference: /watchdog:help\n'); process.exit(1); diff --git a/test/setup.test.js b/test/setup.test.js index d0ffec1..fb49eb3 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -145,6 +145,254 @@ test('setup joins multi-word positional args with spaces', () => { fs.unlinkSync(stateFile); }); +test('setup --prompt-file reads file content as the prompt', () => { + const pid = 200010; + const promptText = '# Task\n\nDo the thing with `code` and "quotes" and $vars.'; + const promptFile = path.join(tmpDir, 'prompt-basic.txt'); + fs.writeFileSync(promptFile, promptText); + + const result = runSetup(['--prompt-file', promptFile], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + assert.equal(result.stdout, `${promptText}\n`); + assert.equal(result.stderr, ''); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file preserves multi-line content with shell metacharacters', () => { + const pid = 200011; + // Exactly the kind of content that breaks `$ARGUMENTS` substitution: + // unescaped quotes, backticks, dollar signs, literal newlines, and a + // Markdown code fence. The file path bypasses shell entirely. + const promptText = [ + '# 任务: 为 PallasAI 构建知识库', + '', + '## 使命', + '', + '把所有 "业务逻辑" 和 `API` 调用关系梳理进 $KB_DIR', + '', + '```bash', + 'echo "hello" && cat file.txt | jq .name', + '```', + '', + '最终消费者是 AI agent。', + ].join('\n'); + const promptFile = path.join(tmpDir, 'prompt-multiline.txt'); + fs.writeFileSync(promptFile, promptText); + + const result = runSetup( + ['--prompt-file', promptFile, '--max-iterations', '15'], + { WATCHDOG_CLAUDE_PID: String(pid) } + ); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + assert.equal(result.stdout, `${promptText}\n`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + assert.equal(state.max_iterations, 15); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file trims surrounding whitespace but keeps interior newlines', () => { + const pid = 200012; + const core = 'Line 1\nLine 2\nLine 3'; + const promptFile = path.join(tmpDir, 'prompt-whitespace.txt'); + fs.writeFileSync(promptFile, `\n\n ${core} \n\n\n`); + + const result = runSetup(['--prompt-file', promptFile], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, core); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file with missing path => exit 1 with clear error', () => { + const missing = path.join(tmpDir, 'does-not-exist.txt'); + const result = runSetup(['--prompt-file', missing], { WATCHDOG_CLAUDE_PID: '1' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /prompt file not found/i); + assert.match(result.stderr, /does-not-exist\.txt/); +}); + +test('setup --prompt-file pointing at a directory => exit 1', () => { + const result = runSetup(['--prompt-file', tmpDir], { WATCHDOG_CLAUDE_PID: '1' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /directory/i); +}); + +test('setup --prompt-file with empty file => exit 1', () => { + const emptyFile = path.join(tmpDir, 'empty.txt'); + fs.writeFileSync(emptyFile, ' \n\n '); + const result = runSetup(['--prompt-file', emptyFile], { WATCHDOG_CLAUDE_PID: '1' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /empty/i); + fs.unlinkSync(emptyFile); +}); + +test('setup --prompt-file without a path argument => exit 1', () => { + const result = runSetup(['--prompt-file'], { WATCHDOG_CLAUDE_PID: '1' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--prompt-file requires/); +}); + +test('setup rejects --prompt-file combined with an inline positional prompt', () => { + const promptFile = path.join(tmpDir, 'prompt-conflict.txt'); + fs.writeFileSync(promptFile, 'from file'); + const result = runSetup( + ['inline prompt', '--prompt-file', promptFile], + { WATCHDOG_CLAUDE_PID: '1' } + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /cannot be combined/i); + // State file must NOT have been created on the conflict path. + assert.equal(fs.existsSync(stateFileFor(1)), false); + fs.unlinkSync(promptFile); +}); + +test('setup --help lists the --prompt-file usage form', () => { + const result = runSetup(['--help']); + assert.equal(result.status, 0); + assert.match(result.stderr, /--prompt-file/); +}); + +test('setup --prompt-file strips a UTF-8 BOM (Windows Notepad scenario)', () => { + const pid = 200013; + const promptText = '# 中文任务\n\n做某件事'; + const promptFile = path.join(tmpDir, 'prompt-bom.txt'); + // Write literal UTF-8 BOM bytes (EF BB BF) + the text. Using a Buffer + // avoids accidentally stripping the BOM via fs's internal decoding. + fs.writeFileSync( + promptFile, + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(promptText, 'utf8')]) + ); + + const result = runSetup(['--prompt-file', promptFile], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + // stdout must NOT start with U+FEFF — otherwise Claude sees an invisible + // marker as the first char of the prompt. + assert.equal(result.stdout.charCodeAt(0), '#'.charCodeAt(0)); + assert.equal(result.stdout, `${promptText}\n`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + assert.equal(state.prompt.charCodeAt(0), '#'.charCodeAt(0)); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file accepts a relative path (resolved against cwd)', () => { + const pid = 200014; + const promptText = 'relative path prompt'; + // Write the file inside tmpDir, then pass just the basename — setup runs + // with cwd: tmpDir (see runSetup()), so a bare filename must resolve. + const basename = 'prompt-relative.txt'; + const promptFile = path.join(tmpDir, basename); + fs.writeFileSync(promptFile, promptText); + + const result = runSetup(['--prompt-file', basename], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + assert.equal(result.stdout, `${promptText}\n`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file accepts "./name" style relative path', () => { + const pid = 200015; + const promptText = 'dot-slash prompt'; + const basename = 'prompt-dotslash.txt'; + const promptFile = path.join(tmpDir, basename); + fs.writeFileSync(promptFile, promptText); + + const result = runSetup(['--prompt-file', `./${basename}`], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file preserves CRLF line endings inside content', () => { + const pid = 200016; + // Windows-style line endings. We do NOT convert these — the user's + // prompt should reach Claude byte-for-byte (minus BOM and surrounding + // whitespace). Claude handles CRLF fine in practice. + const promptText = 'line one\r\nline two\r\nline three'; + const promptFile = path.join(tmpDir, 'prompt-crlf.txt'); + fs.writeFileSync(promptFile, promptText); + + const result = runSetup(['--prompt-file', promptFile], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file accepts a filename with non-ASCII chars', () => { + const pid = 200017; + const promptText = 'unicode filename test'; + const promptFile = path.join(tmpDir, '提示词-测试.txt'); + fs.writeFileSync(promptFile, promptText); + + const result = runSetup(['--prompt-file', promptFile], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(promptFile); +}); + +test('setup --prompt-file follows a symlink to the real file', { skip: process.platform === 'win32' }, () => { + // Symlink creation on Windows requires elevated privileges or developer + // mode, so skip there. On Linux/Mac fs.readFileSync follows symlinks + // transparently — this test guards against someone "fixing" that by + // switching to lstat/readlink. + const pid = 200018; + const promptText = 'via symlink'; + const realFile = path.join(tmpDir, 'prompt-real.txt'); + const linkFile = path.join(tmpDir, 'prompt-link.txt'); + fs.writeFileSync(realFile, promptText); + fs.symlinkSync(realFile, linkFile); + + const result = runSetup(['--prompt-file', linkFile], { WATCHDOG_CLAUDE_PID: String(pid) }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + + const state = JSON.parse(fs.readFileSync(stateFileFor(pid), 'utf8')); + assert.equal(state.prompt, promptText); + fs.unlinkSync(stateFileFor(pid)); + fs.unlinkSync(linkFile); + fs.unlinkSync(realFile); +}); + +test('setup --prompt-file on unreadable file => exit 1 with permission error', { skip: process.platform === 'win32' || process.getuid?.() === 0 }, () => { + // POSIX-only: chmod 0 the file so even the owner can't read it. Root + // ignores file mode, so skip when running as root (common in CI + // containers). + const promptFile = path.join(tmpDir, 'prompt-unreadable.txt'); + fs.writeFileSync(promptFile, 'secret'); + fs.chmodSync(promptFile, 0o000); + try { + const result = runSetup(['--prompt-file', promptFile], { WATCHDOG_CLAUDE_PID: '1' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /permission denied/i); + } finally { + fs.chmodSync(promptFile, 0o600); + fs.unlinkSync(promptFile); + } +}); + test('concurrent setups with different claudePids produce independent state files', () => { // Simulate two concurrent Claude Code sessions in the same repo. runSetup(['session A task'], { WATCHDOG_CLAUDE_PID: '300001' });