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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user