From 75add58246441d30e767355f29c8e0d7488af196 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Sun, 2 Aug 2026 21:32:35 +1200 Subject: [PATCH] idle-draft: per-work-type allowed_tools; research must web-search for stories Research and topic_ideas runs get --allowedTools WebSearch,WebFetch; research template mandates >=1 web search for related stories with full-URL + access-date citations under the dossier's status labels. Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc --- data/idle-draft/config.example.json | 4 +- data/idle-draft/prompts/research.md | 27 +++++++++ data/idle-draft/prompts/topic-ideas.md | 11 ++++ scripts/idle-draft | 24 +++++++- specs/idle-draft.spec.md | 19 +++++- tests/test-idle-draft.sh | 81 +++++++++++++++++++++++++- 6 files changed, 158 insertions(+), 8 deletions(-) diff --git a/data/idle-draft/config.example.json b/data/idle-draft/config.example.json index 186a7a1..0d2afbd 100644 --- a/data/idle-draft/config.example.json +++ b/data/idle-draft/config.example.json @@ -9,8 +9,8 @@ "work_types": { "review": { "providers": ["anthropic"] }, "draft": { "providers": ["anthropic"] }, - "research": { "providers": ["anthropic", "minimax"] }, - "topic_ideas": { "providers": ["anthropic", "minimax"] } + "research": { "providers": ["anthropic", "minimax"], "allowed_tools": ["WebSearch", "WebFetch"] }, + "topic_ideas": { "providers": ["anthropic", "minimax"], "allowed_tools": ["WebSearch", "WebFetch"] } }, "dossiers": ["ai", "ai-technical", "devops-2020", "devops-2020-technical"], "review_score_threshold": 11, diff --git a/data/idle-draft/prompts/research.md b/data/idle-draft/prompts/research.md index 8e9fd9f..bda67e5 100644 --- a/data/idle-draft/prompts/research.md +++ b/data/idle-draft/prompts/research.md @@ -30,6 +30,33 @@ anything new and relevant you find under the evidence directories above: for a reader (a scene, a named organisation's experience, a practitioner's account) — still sourced, but framed as narrative rather than a bare statistic. +## Required: web search for current stories (before writing Subjective stories) + +You have `WebSearch` and `WebFetch` available. Before writing the **Subjective +stories** section, run **at least one `WebSearch`** for recent stories, case +studies, or practitioner accounts directly related to this topic — derive your +query terms from the topic brief (`$overview_path`) and commissioning brief +(`$agent_path`), not from the dossier title alone. This is not optional and not +decorative: a research file with zero web search calls and zero web-sourced +entries is incomplete. + +- Web-sourced entries go in Subjective stories (or Objective evidence, if the + find is a concrete measurable rather than a narrative) alongside the local + evidence-dir findings — web search **complements** the local evidence dirs, + it never replaces them. Local evidence-dir citation rules above are + unchanged. +- Cite the **full URL** and the **access date** (today's date) for every + web-sourced entry, e.g. `https://example.com/post (accessed 2026-08-02)`. +- Apply the dossier's status-label family correctly to web sources: a company + engineering-blog post is "Named organisation, self-reported"; vendor + marketing or a vendor's own benchmark is "Vendor data, treat with caution"; + an industry survey is "Research-survey finding"; use "GOES synthesis / bet" + only for your own synthesis, never for a source you found. +- **Only cite a URL actually returned by `WebSearch` or fetched via + `WebFetch`.** Never construct, guess, or paraphrase a plausible-looking URL. + A fabricated URL is grounds for outright rejection at human sampling — + treat it as equivalent to citing a dead file path. + ## Citation rules (non-negotiable — read `$agents_root_path` for the full statement) - **Cite the file, not the concept.** Any code or spec reference must name the file diff --git a/data/idle-draft/prompts/topic-ideas.md b/data/idle-draft/prompts/topic-ideas.md index 1376842..cd0d9f0 100644 --- a/data/idle-draft/prompts/topic-ideas.md +++ b/data/idle-draft/prompts/topic-ideas.md @@ -22,6 +22,17 @@ Do **not** propose anything that duplicates or trivially rephrases one of these: $existing_titles +## Check current discussion first + +You have `WebSearch` available. Before proposing, run at least one `WebSearch` +to check what is currently being discussed in this dossier's topic area — this +keeps proposals grounded in what practitioners are actually talking about +right now, not just what the local evidence dirs already contain. This is a +lighter-touch check than the research work type's requirement: you do not need +to cite web sources in the proposal itself (evidence pointers below still come +from the evidence directories), just use the search to sanity-check relevance +and avoid pitching something already stale or already covered elsewhere. + ## What to produce Propose **up to $max_new** new candidate topics for this dossier, each grounded in diff --git a/scripts/idle-draft b/scripts/idle-draft index 5617787..9c6e1d1 100755 --- a/scripts/idle-draft +++ b/scripts/idle-draft @@ -223,6 +223,10 @@ def load_config(path: Path) -> dict: for wtype, wcfg in data["work_types"].items(): if "providers" not in wcfg or not isinstance(wcfg["providers"], list): raise ConfigError(f"work_type '{wtype}' missing 'providers' list") + if "allowed_tools" in wcfg: + at = wcfg["allowed_tools"] + if not isinstance(at, list) or not all(isinstance(x, str) for x in at): + raise ConfigError(f"work_type '{wtype}' 'allowed_tools' must be a list of strings") return data @@ -749,12 +753,16 @@ def validate_output(work_type: str, text: str) -> tuple[bool, str]: # === Claude argv / execution === -def build_claude_argv(model_id: str | None, max_turns: int, add_dirs: list[str]) -> list[str]: +def build_claude_argv( + model_id: str | None, max_turns: int, add_dirs: list[str], allowed_tools: list[str] | None = None +) -> list[str]: argv = ["claude", "-p", "--max-turns", str(max_turns)] if model_id: argv += ["--model", model_id] for d in add_dirs: argv += ["--add-dir", d] + if allowed_tools: + argv += ["--allowedTools", ",".join(allowed_tools)] return argv @@ -801,6 +809,14 @@ def resolve_add_dirs(config: dict, work_type: str) -> list[str]: return [] +def resolve_allowed_tools(config: dict, work_type: str) -> list[str]: + """Tool names granted to the headless child via --allowedTools, from + config["work_types"][work_type]["allowed_tools"]. Empty/absent -> [] + (caller omits the flag; headless runs cannot answer permission prompts, + so only tools explicitly listed here are usable by that work type).""" + return list(config["work_types"].get(work_type, {}).get("allowed_tools") or []) + + def dispatch_preview(repo: Path, config: dict, candidate: dict, max_turns: int) -> dict: """Compute everything a dryrun needs to print, without executing anything.""" dossier = candidate["dossier"] @@ -809,7 +825,8 @@ def dispatch_preview(repo: Path, config: dict, candidate: dict, max_turns: int) profile_dir = expand(config["providers"][provider]["profile"]) model_id = resolve_model_id(profile_dir) add_dirs = resolve_add_dirs(config, work_type) - argv = build_claude_argv(model_id, max_turns, add_dirs) + allowed_tools = resolve_allowed_tools(config, work_type) + argv = build_claude_argv(model_id, max_turns, add_dirs, allowed_tools) canonical = canonical_output_path(repo, candidate) tmp_path = canonical.with_name(canonical.name + ".tmp") parsed_env = parse_provider_env(profile_dir / "provider.env") @@ -847,7 +864,8 @@ def run_task(repo: Path, config: dict, candidate: dict, max_turns: int, task_tim profile_dir = expand(config["providers"][provider]["profile"]) model_id = resolve_model_id(profile_dir) add_dirs = resolve_add_dirs(config, work_type) - argv = build_claude_argv(model_id, max_turns, add_dirs) + allowed_tools = resolve_allowed_tools(config, work_type) + argv = build_claude_argv(model_id, max_turns, add_dirs, allowed_tools) env = build_child_env(profile_dir) timed_out = False diff --git a/specs/idle-draft.spec.md b/specs/idle-draft.spec.md index 09481d8..d195f34 100644 --- a/specs/idle-draft.spec.md +++ b/specs/idle-draft.spec.md @@ -228,7 +228,12 @@ For the selected `(item_or_dossier, work_type, provider)`: 4. Build the argv: `claude -p --max-turns ` (`N` = 25 unless overridden), plus `--model ` if `provider.env` sets `MODEL_ID`, plus `--add-dir ` for each entry in `config["evidence_dirs"]` **only** for `research` and `topic_ideas` work - types (the only ones that cite external evidence). + types (the only ones that cite external evidence), plus `--allowedTools + ""` if `config["work_types"][work_type]["allowed_tools"]` is + present and non-empty (see Config schema below). Headless `claude -p` runs cannot + answer permission prompts, so any tool beyond the CLI's defaults must be granted + this way or the child run stalls/fails; when the key is absent or an empty list, + the flag is omitted entirely (unchanged pre-existing behaviour). 5. Run the subprocess: `cwd=`, `env=`, prompt piped via **stdin** (not as an argv element — avoids `ARG_MAX` on large rendered prompts, same lesson `claude-profile` already applies to its system-prompt injection), timeout = per-task @@ -311,6 +316,17 @@ already says) is never written here. `idle-draft.config.json` — see `data/idle-draft/config.example.json` for the exact structure. All top-level keys listed in §3 are required. +Each entry in `work_types` may carry an optional `allowed_tools` key: a list of Claude +Code tool names (e.g. `["WebSearch", "WebFetch"]`) granted to that work type's headless +child via `--allowedTools` (§11 step 4). Present-but-empty and absent are both treated +as "no extra tools" — the flag is omitted. If present, it must be a JSON array of +strings; any other shape (a string, a number, a list containing a non-string) is a +config validation failure — exit **2** at config load, same as a missing required key. +`config.example.json` sets `allowed_tools: ["WebSearch", "WebFetch"]` on `research` and +`topic_ideas` (the research prompt template requires at least one `WebSearch` call +before writing its Subjective stories section; the topic-ideas template requires one as +a lighter-touch check); `draft` and `review` carry no `allowed_tools` key. + ## Failure classes | Class | Examples | `attempts` effect | Item outcome | @@ -344,6 +360,7 @@ structure. All top-level keys listed in §3 are required. | `--probe-json` combined with `--dryrun` | Composes normally — no subprocess call either way | | `--once` with no eligible work | Exits 0 immediately, no task dispatched | | Config missing a required key | Exit 2 | +| `work_types[*].allowed_tools` present but not a list of strings | Exit 2 | | `agent-subscriptions` subprocess fails entirely | Exit 1, loud log line, no dispatch attempted | ## Examples diff --git a/tests/test-idle-draft.sh b/tests/test-idle-draft.sh index ef2645e..78bb84d 100755 --- a/tests/test-idle-draft.sh +++ b/tests/test-idle-draft.sh @@ -88,8 +88,8 @@ cat > "$FIXTURE_CONFIG" < "$BADALLOWED" <&1) +code=$? +assert_exit_code "$code" 2 "allowed_tools not a list of strings exits 2" +assert_contains "$output" "allowed_tools" "allowed_tools config error names the offending key" + echo "-- mark subcommand round-trip --" MARKREPO="$TMPDIR/mark-repo" mkdir -p "$MARKREPO/ai" @@ -398,6 +426,55 @@ check("build_child_env: profile without provider.env gets only CLAUDE_CONFIG_DIR "ANTHROPIC_BASE_URL" not in env2 and "ANTHROPIC_API_KEY" not in env2 and env2["CLAUDE_CONFIG_DIR"] == str(no_provider_env_dir), env2) +# --- allowed_tools / --allowedTools argv --- +argv = m.build_claude_argv("model-x", 25, ["/tmp/ev"], ["WebSearch", "WebFetch"]) +check("build_claude_argv: appends --allowedTools with comma-joined list when provided", + "--allowedTools" in argv and argv[argv.index("--allowedTools") + 1] == "WebSearch,WebFetch", argv) + +argv_empty = m.build_claude_argv("model-x", 25, ["/tmp/ev"], []) +check("build_claude_argv: omits --allowedTools when allowed_tools is an empty list", "--allowedTools" not in argv_empty, argv_empty) + +argv_none = m.build_claude_argv("model-x", 25, ["/tmp/ev"], None) +check("build_claude_argv: omits --allowedTools when allowed_tools is None", "--allowedTools" not in argv_none, argv_none) + +cfg_at = { + "work_types": { + "research": {"providers": ["anthropic"], "allowed_tools": ["WebSearch", "WebFetch"]}, + "draft": {"providers": ["anthropic"]}, + } +} +check("resolve_allowed_tools: returns configured list for research", + m.resolve_allowed_tools(cfg_at, "research") == ["WebSearch", "WebFetch"], m.resolve_allowed_tools(cfg_at, "research")) +check("resolve_allowed_tools: returns [] when work_type has no allowed_tools key", + m.resolve_allowed_tools(cfg_at, "draft") == [], m.resolve_allowed_tools(cfg_at, "draft")) +check("resolve_allowed_tools: returns [] for a work_type not present in config at all", + m.resolve_allowed_tools(cfg_at, "review") == [], m.resolve_allowed_tools(cfg_at, "review")) + +bad_allowed_cfg_path = tmpdir / "bad-allowed-direct.config.json" +bad_allowed_cfg = json.loads((tmpdir / "idle-draft.config.json").read_text()) +bad_allowed_cfg["work_types"]["research"]["allowed_tools"] = "WebSearch" +bad_allowed_cfg_path.write_text(json.dumps(bad_allowed_cfg)) +try: + m.load_config(bad_allowed_cfg_path) + check("load_config: allowed_tools as a bare string (not a list) raises ConfigError", False) +except m.ConfigError as e: + check("load_config: allowed_tools as a bare string (not a list) raises ConfigError", "allowed_tools" in str(e), str(e)) + +bad_allowed_cfg2_path = tmpdir / "bad-allowed-direct2.config.json" +bad_allowed_cfg2 = json.loads((tmpdir / "idle-draft.config.json").read_text()) +bad_allowed_cfg2["work_types"]["research"]["allowed_tools"] = ["WebSearch", 5] +bad_allowed_cfg2_path.write_text(json.dumps(bad_allowed_cfg2)) +try: + m.load_config(bad_allowed_cfg2_path) + check("load_config: allowed_tools list containing a non-string raises ConfigError", False) +except m.ConfigError as e: + check("load_config: allowed_tools list containing a non-string raises ConfigError", "allowed_tools" in str(e), str(e)) + +good_allowed_cfg = m.load_config(tmpdir / "idle-draft.config.json") +check("load_config: well-formed allowed_tools (list of strings) loads without error", + good_allowed_cfg["work_types"]["research"]["allowed_tools"] == ["WebSearch", "WebFetch"], + good_allowed_cfg["work_types"]["research"]) + # --- Citation validation --- real_path = cred_repo / "exists.txt" real_path.write_text("x")