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
This commit is contained in:
Paul O'Reilly
2026-08-02 21:32:35 +12:00
parent cd5be1cf92
commit 75add58246
6 changed files with 158 additions and 8 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -228,7 +228,12 @@ For the selected `(item_or_dossier, work_type, provider)`:
4. Build the argv: `claude -p --max-turns <N>` (`N` = 25 unless overridden), plus
`--model <id>` if `provider.env` sets `MODEL_ID`, plus `--add-dir <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
"<comma-joined list>"` 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=<repo>`, `env=<built 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

View File

@@ -88,8 +88,8 @@ cat > "$FIXTURE_CONFIG" <<EOF
"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"],
"review_score_threshold": 11,
@@ -135,6 +135,7 @@ assert_contains "$output" "item: ai/03-topic-three" "dryrun picks item 03 (
assert_contains "$output" "work_type: review" "dryrun names work_type review"
assert_contains "$output" "provider: anthropic" "dryrun names provider anthropic"
assert_not_contains "$output" "would run" "dryrun does not execute claude"
assert_not_contains "$output" "--allowedTools" "dryrun argv for review (no allowed_tools configured) omits the flag"
echo "-- dryrun: exact resolved claude argv for a research item --"
# Force only the research-ready item to be eligible by pointing --repo at a
@@ -151,6 +152,8 @@ output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$SOLO" --probe-json "$FIXT
assert_contains "$output" "work_type: research" "dryrun (solo fixture) picks research"
assert_contains "$output" "argv: ['claude', '-p', '--max-turns', '25', '--add-dir'" "dryrun prints resolved claude argv with --add-dir for research"
assert_contains "$output" "$TMPDIR/evidence" "dryrun argv includes configured evidence dir"
assert_contains "$output" "--allowedTools" "dryrun argv for research includes --allowedTools (allowed_tools configured)"
assert_contains "$output" "WebSearch,WebFetch" "dryrun argv --allowedTools value is the comma-joined tool list"
echo "-- dryrun: no mutation --"
before_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
@@ -182,6 +185,31 @@ output=$("$SCRIPT" --config "$BADCONFIG" --repo "$FIXTURE_REPO" --probe-json "$F
code=$?
assert_exit_code "$code" 2 "config missing required keys exits 2"
BADALLOWED="$TMPDIR/bad-allowed.config.json"
cat > "$BADALLOWED" <<EOF
{
"parallel": 2,
"providers": {
"anthropic": { "profile": "$PROFILE_ANTHROPIC", "threshold_pct": 80, "five_hour_ceiling": 50, "min_idle": 5 }
},
"work_types": {
"review": { "providers": ["anthropic"] },
"draft": { "providers": ["anthropic"] },
"research": { "providers": ["anthropic"], "allowed_tools": "WebSearch" },
"topic_ideas": { "providers": ["anthropic"] }
},
"dossiers": ["ai"],
"review_score_threshold": 11,
"max_unreviewed_research_per_dossier": 3,
"max_open_topic_proposals": 6,
"evidence_dirs": ["$TMPDIR/evidence"]
}
EOF
output=$("$SCRIPT" --config "$BADALLOWED" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --dryrun 2>&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")