A step-by-step guide to running the feature-loop policy plugin together with Agent Teams on a small task, so you can watch budget ceilings, the review gate, and the detectors actually fire — and approve a gated step yourself in the browser.
Everything below was executed and verified on this machine
(macOS, Node 22.23.2, DSH checkout at
~/work/harvey/freepeak/deepseek-harness). Commands are copy-pasteable.
Already running? A verified live instance is described in
docs/RUNBOOK-SERVER.md, which opens with an acceptance checklist (§0) of what "it works" looks like, then the exact boot command, the URL, and §2.5-2.6 on the permission preset that silently disables the approval prompt. Read §0 and §2.5 before debugging a missing panel.
Two independent things, deliberately kept separate:
| Piece | Package | What it does |
|---|---|---|
| Feature loop | @freepeak/dsh-feature-loop |
Budget ceilings, cheap-first routing, review gate, 6 stall detectors, local judge — hosted on the agent loop's own extension points |
| Agent Teams | @deepseek-ai/dsh-experimental-agent-team-profile |
Parallel teammates with a durable mailbox and a shared task DAG |
They solve different problems and do not overlap. The feature loop governs one bounded loop (when to stop, when to ask, when to spend no more). Agent Teams governs many agents working in parallel. You want both when the Lead should delegate work and each worker should be bounded.
Read that once more before you start: the feature loop is not a replacement for Agent Teams, and Agent Teams is not a replacement for the feature loop.
The third piece is already built and you do not write it. The Web UI approval panel, the roster/task-board panel, and the session views ship with DSH. Your job is to install the plugin so the gate reaches them.
| Docker (recommended) | Local checkout | |
|---|---|---|
| You need | Docker, and a gateway key | Node 22, pnpm, a harness checkout |
| Steps | one command | build the plugin, create a profile, install, patch, boot |
| Guide | ../docker/README.md |
this file, from Step 1 |
Docker is the shorter path and the one that is known to work end to end. It
installs the harness CLI and its bundles from npm, builds only this plugin, and
pins the permission preset that makes the approval panel appear. Start there
unless you specifically want to run against a local deepseek-harness checkout.
cd dsh-feature-loop
ONEGW_API_KEY=sk-... docker compose -f docker/docker-compose.yml up --build
docker compose -f docker/docker-compose.yml logs -f # the URL + tokenThe rest of this file is the local path. One caveat up front, because it
bites on a clean clone: pnpm build here assumes a populated node_modules
(see Step 1).
node --version # must be >= 22 (--experimental-strip-types)
dsh --version 2>/dev/null || echo "dsh is invoked from the checkout"DSH is run from its checkout in this guide:
DSH=~/work/harvey/freepeak/deepseek-harness
node "$DSH/apps/cli/lib/bin.js" --helpThe plugin path (steps 1-7) needs no key of its own — it uses whatever the
harness is configured with. The standalone runner demo (bash demo/run.sh)
does need a gateway key, read from the environment or the DSH credential store:
# either export it…
export ONEGW_API_KEY=...
# …or rely on the store DSH itself populates:
# ~/.dsh/.credentials.yaml -> refs:
# ONEGW_API_KEY: ...Both ONEGW_API_KEY and ONEGE_API_KEY are accepted. If demo/run.sh fails
with:
no gateway key: set ONEGW_API_KEY (or ONEGE_API_KEY) in the environment,
or add it to ~/.dsh/.credentials.yaml
…then check the name under refs: in that file. The key being present under a
different name is the usual cause — an earlier version of cli.ts looked
only for ONEGE_API_KEY while the store records ONEGW_API_KEY, and reported
the key as absent from a file that contained it.
If the CLI is not built:
cd "$DSH" && pnpm run build # or: pnpm run build:libThe harness loads a plugin as built JavaScript, resolved by package name. It
does not load .ts sources, so the build is not optional.
cd ~/work/harvey/freepeak/dsh-feature-loop
pnpm install # installs tsdown + typescript only
pnpm build # tsdown src/index.ts --format esm --dts --out-dir libExpected output (sizes drift with the bundler; Build complete is the signal):
lib/index.mjs 42.08 kB │ gzip: 13.83 kB
lib/index.d.mts 30.04 kB │ gzip: 9.93 kB
✔ Build complete
pnpm installinstalls three packages, not the harness (typescript,@types/node, tsdown). The@deepseek-ai/*packages inpeerDependenciesare supplied by the deployment at runtime; they are marked optional and.npmrcsetsauto-install-peers=false, because some of their transitive dependencies are harness-internal (@deepseek-ai/dsh-type-meta) and are not on npm — auto-installing peers fails the whole install.This is why
pnpm installused to be impossible here: the manifest pinned@deepseek-ai/cordis@0.4.0and@deepseek-ai/schemastery@0.1.5, both harness-monorepo workspace versions that were never published. They are no longer devDependencies.
Verify the build produced the cordis contract the harness requires:
grep -c "export {" lib/index.mjs && grep -o "apply" lib/index.mjs | head -1
# 1
# applyDo not try to
import('./lib/index.mjs')at this point. It will fail withERR_MODULE_NOT_FOUNDfor@deepseek-ai/dsh-llm, and that is expected — the bundle importsMessageIdfrom a harness package at runtime, and harness packages are peers supplied by the deployment, which is whypnpm installdeliberately does not fetch them. The import only resolves inside a DSH profile, which is what Step 6 boots. A clean-cloneimportfailing here means the manifest is behaving correctly, not that the build is broken.
No local harness checkout? This path needs one (Steps 2-7 install the plugin into a DSH profile). If you only want a working server, use the Docker path above — it needs no toolchain and no checkout.
Do not test in your working web profile. Make a throwaway one; if the
plugin misbehaves, you delete a directory instead of repairing your daily setup.
cd ~/.dsh/profiles
mkdir -p fltest && cd fltest
# Copy the profile scaffolding (an empty root + your user patch layer).
cp ../web/cordis.yml cordis.yml
cp ../web/pnpm-workspace.yaml pnpm-workspace.yaml
cp ../web/cordis.patch.yml cordis.patch.ymlNow the profile manifest. This lists the bundles that compose the profile, in order — note that both the team bundles and the feature-loop plugin are present:
cat > package.json <<'JSON'
{
"name": "dsh-profile-fltest",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"@deepseek-ai/dsh-experimental-agent-team-web-profile",
"@deepseek-ai/dsh-experimental-agent-team-profile",
"@freepeak/dsh-feature-loop"
],
"patchReload": "live"
}
}
}
JSONagent-team-profile inserts the team service and tools;
agent-team-web-profile adds the UI for them. On a server profile you want
both. The team layer also disables the older subagent / subagent_fork tools
so that direct delegation goes through spawn_teammate instead — that is
intentional, and it is why the delegation tool names change once teams are on.
This is the most expensive failure in
KNOWN-ISSUES.md §4, and it is worth the two lines here.
The built plugin imports two harness packages at runtime —
@deepseek-ai/dsh-llm and @deepseek-ai/dsh-typert-protocol — and both are
peerDependencies, both optional, and .npmrc sets auto-install-peers: false,
so pnpm never installs them. A profile that depends on nothing else that
pulls the harness therefore installs the plugin cleanly, composes it
(--dump-config shows all three rows, and the client row is in the boot graph),
and then does absolutely nothing: no gate, no review, no approval.
Two details make this failure quiet, and both are worth knowing before you debug the wrong thing:
-
A row in the boot graph is not proof the plugin works.
client-modulesreadsdsh.clientfrompackage.jsonon disk and never imports the module, so a plugin that cannot import still gets a graph row. -
The store key tells you whether the peers resolved. pnpm encodes them in the virtual-store directory name:
@freepeak+dsh-feature-loop@file+…dsh-feature-loop # peers NOT resolved @freepeak+dsh-feature-loop@file+…dsh-feature-loop_@deepseek-ai+c_q7p3s2fiohg… # peers resolved
The fix is one dependency: depend on a bundle that brings the harness packages
into the profile's store. @deepseek-ai/dsh-experimental-agent-team-profile
(which this guide already installs for Agent Teams) does. If you install the
plugin alone, add it or another harness bundle explicitly.
Two more gotchas when building such a profile by hand, both reproduced here:
- Use the same pnpm major as the lockfile.
pnpm@11silently re-resolved a v9 lockfile and dropped the peer wiring.npx pnpm@9.15.9 install --frozen-lockfilereproduced the working install. - Boot a named profile without an app argument.
bin.js --profile X --port Nboots X's bundle tree.bin.js web --port Nboots the default web app and ignores the profile's bundles — the plugin composes in--dump-configand is still never mounted.
Use the official command. It forwards to pnpm inside the profile directory.
DSH=~/work/harvey/freepeak/deepseek-harness
node "$DSH/apps/cli/lib/bin.js" plugin --profile fltest \
add -w file:~/work/harvey/freepeak/dsh-feature-loop
-wis required. Without it pnpm refuses withERR_PNPM_ADDING_TO_ROOT, because a DSH profile is a single-package pnpm workspace root. This is the most common stumble in this setup.
Expected:
dependencies:
+ @freepeak/dsh-feature-loop 0.1.0
Done in 2s using pnpm v9.15.9
The plugin registers itself with no spec by default, which means it loads and does nothing. That is deliberate: the row is safe to add before you have decided on a budget. Edit the profile's patch layer:
cd ~/.dsh/profiles/fltest
$EDITOR cordis.patch.ymlAppend one entry to that file — do not assume it is empty:
- If the file is still the template, it is a single empty document,
[]. Delete that line and put the entry below in its place. (Appending a block sequence after a flow[]is invalid YAML and the loader rejects the whole overlay:failed to parse overlay ... end of the stream or a document separator is expected.) - If the profile was copied from a customized
webprofile, the file already holds your own patch entries (and a plugin's own bundle patch may also insert afeature-looprow). Keep them and append — a later entry for the sameidwins, which is exactly how this activation works.
# Ask a human about at most 10% of steps; judge score >= 2 earns a look.
- id: feature-loop
config:
reviewBudget: 0.10
judgeThreshold: 2
gatePolicies:
read: auto # reads never interrupt
glob: auto
grep: auto
edit: auto-if-confident # writes need a confidence estimate
write: auto-if-confident
# How a review reaches a human. `ask` (the default) prompts in the Web UI
# composer so you can allow or reject. It fails closed to a refusal when no
# approval channel is mounted, so it is never less safe than `deny`.
# Use `deny` for unattended/CI runs where nobody is watching.
gateMode: ask
spec:
goal: "the failing test passes and no other test breaks"
sensor: ["repo files", "test output"]
controller:
ladder:
- { provider: deepseek-official, model: deepseek-flash } # cheap
- { provider: deepseek-official, model: deepseek-v4-pro } # escalated
stepsPerRung: 5
escalateAfterFailures: 2
actuator:
read: read
glob: read
grep: read
bash: irreversible # arbitrary command execution
edit: reversible-write
write: irreversible
feedback: "all tests pass, and the diff is the smallest that achieves it"
termination:
successCommand: "npm test"
guards: ["error-cascade", "tool-cycle"]
maxSteps: 15
costBudgetUSD: 1.00
prices:
deepseek-official/deepseek-flash:
inputPerMTok: 0.14
outputPerMTok: 0.28
cacheReadPerMTok: 0.014
deepseek-official/deepseek-v4-pro:
inputPerMTok: 2.50
outputPerMTok: 10.00- Use the harness's real tool names. The model in a DSH session calls
read,write,edit,bash,glob,grep— notread_file/write_file/edit_file/list_files/run_tests. The latter names exist only in this repo's standalone runner (src/tools.ts) and never appear in a harness session. A name that matches nothing is not an error: the tool falls through to the unclassifiedirreversibledefault, so the gate still fires — but withalways-approveinstead of the policy you meant to set. You would then be reading a gate that works for the wrong reason. - All eight dimensions are required.
validateSpecrejects an incomplete spec at load, naming every missing dimension. If DSH refuses to boot, read the error — it tells you exactly which field is missing. pricesis not optional in practice. A cost budget measured against an unknown price silently reads zero. An unpriced route is a hard error by design.- An
irreversibleactuator entry needs a termination guard. Spec validation enforces this: if you declare an irreversible tool and provide noguards, the gate is your only containment and the validator says so. Keeperror-cascadeandtool-cycle.
Before booting, confirm both layers resolve. This is the cheapest check and it catches every packaging mistake:
DSH=~/work/harvey/freepeak/deepseek-harness
node "$DSH/apps/cli/lib/bin.js" --profile fltest --dump-config \
| grep -A 14 -m1 "id: feature-loop"You should see the resolved row. Two details of the real output are worth expecting, because both look like mistakes and are not:
- The row you see is the merged result. The plugin's own bundle patch and
your profile layer both target
id: feature-loop, and the composed dump shows one row carrying your spec — a later entry for the sameidwins. (An earlier version of this guide claimed the row appeared twice; it does not.) Search forgateModeto confirm your settings took. gateMode: askis the line that matters. If your row lacks it, the gate still asks (that is the default) but you have not configured it explicitly.
- id: feature-loop
name: '@freepeak/dsh-feature-loop'
config:
reviewBudget: 0.1
judgeThreshold: 2
gateMode: ask
gatePolicies: { read: auto, edit: auto-if-confident, ... }And confirm Agent Teams composed too:
node "$DSH/apps/cli/lib/bin.js" --profile fltest --dump-config \
| grep -E "agent-team|tool-agent-team"
# - id: ui-agent-team
# - id: agent-team
# - id: tool-agent-teamIf feature-loop is missing, the package did not install (step 3). If the
config: block is empty, your patch layer did not take (step 4).
DSH=~/work/harvey/freepeak/deepseek-harness
node "$DSH/apps/cli/lib/bin.js" --profile fltest --port 3099 --no-openExpected:
dsh web: http://127.0.0.1:3099/?token=...
Open that URL (it carries the auth token). Use a port other than 3081 unless you intend to replace the GUI you are already running.
A clean boot with no MODULE_NOT_FOUND / SyntaxError means the plugin loaded.
Pick something genuinely small — the point is to watch the machinery, not to solve a hard problem. Use the bundled demo bug, which is a real one-line off-by-one with a failing test suite:
cp -r ~/work/harvey/freepeak/dsh-feature-loop/demo /tmp/fl-demo
cd /tmp/fl-demo && bash reset.sh # re-plant the bugNow paste this into the DSH session:
Use Agent Teams to split this between two teammates. Teammate A diagnoses the off-by-one in
/tmp/fl-demo/src/latency-window.tsand reports the exact expression at fault. Teammate B then fixes it and runsnode --experimental-strip-types --test test/latency-window.test.tsuntil it passes. Report the root cause, the diff, and the final test result. Keep it under 10 steps.
This is the whole payoff — the policies are visible in the transcript:
| You should see | Meaning |
|---|---|
| The approval panel in the composer | The gate asked, and the Web UI is waiting on you. Click Allow once to let the step proceed, or Reject to refuse it. |
[review] ASK HUMAN via policy — edit: reversible-write needs a confidence estimate and none was available — asking rather than guessing |
Why it asked. This is fail-closed: no judge ⇒ no evidence of confidence ⇒ ask. (The tail is part of the real message; shortened versions in older notes were wrong.) |
[review] ASK HUMAN via signal |
A critical detector fired (error-cascade, tool-cycle). Critical signals are never rate-limited. |
| A step count that stops at your ceiling | maxSteps fired as a limit, not an invoice — it stops before the expensive call. |
MODEL ESCALATION in a notice |
The ladder moved up a rung on evidence. |
spawn_teammate, send_message, team_task_* |
Agent Teams is live. |
This is the step that trips everyone up. If your DSH settings set
permission.defaultPreset: danger-full-access, that preset maps to
approval: never, and a fresh session applies it after every config default —
so a feature-loop ask is refused with Error: the user rejected tool "X"
before any UI is consulted, and no panel can appear.
The fix needs no file edit: open the permission / preset selector in the
session (the Web UI ships one, client-ui-permission-presets) and choose
workspace-write, whose preset carries approval: ask. That applies per
session — nothing on disk changes.
// what that selector does, packages/interaction/permission-presets/src/index.ts
set(session, 'workspace-write') // -> setApprovalPolicy(session, 'ask')To change it for every DSH session instead, set
~/.dsh/settings.yaml → permission.defaultPreset: workspace-write. Be aware
that also relaxes your global sandbox, so writes outside the workspace begin to
require approval.
Or change nothing globally: run a second server under its own DSH_HOME,
whose private settings.yaml carries the right preset. That server's panel works
immediately. See docs/RUNBOOK-SERVER.md §2.6 for the five
commands, and §2.5 for the full mechanism.
Full mechanism: docs/RUNBOOK-SERVER.md §2.5.
This is the flow to confirm, and the whole reason the gate returns ask:
- The model decides to edit a file. The plugin's gate classifies
editasreversible-write, finds no confidence estimate (no judge is configured), and returns{kind:'ask', reason}— before the tool runs. - DSH routes that
askto the approval service, which asks any composed answerer. In the Web UI,@deepseek-ai/dsh-client-ui-approvaltakes over the conversation composer and shows the reason with Reject / Allow once. - You click Allow once → the outcome is
allowed-once→ the tool dispatches. You click Reject →rejected→ the model is told a human said no, and the step counts as a failure soerror-cascadecan see a loop that keeps hitting the same wall. - The approval is one-shot: it applies to that request only. The next write asks again.
If you get a tool error instead of a panel, check the session's permission preset. If it is
danger-full-access, that preset's approval policy isnever, which rejects every request before any UI is consulted — you will seeError: the user rejected tool "X"and no panel. Select workspace-write in the session's preset selector (or run under a privateDSH_HOMEwhose settings pin it). Seedocs/RUNBOOK-SERVER.md§2.5-2.6.
The fastest way to trust a ceiling is to watch it fire:
maxSteps: 3
costBudgetUSD: 0.00001Restart (or let patchReload: live pick it up) and run the same task. It must
stop early and say why. If it runs past the ceiling, the plugin is not
loaded — go back to step 5.
You do not have to trust the panel blindly. Before or after you click it, run the integration test — it mounts this plugin into a real DSH context (real tool runtime, real approval service, real session) and drives the actual dispatch path:
cd ~/work/harvey/freepeak/dsh-feature-loop
pnpm test:integrationExpected: 5 tests pass. It asserts the four outcomes a human can produce:
| Case | What it proves |
|---|---|
answerer returns allowed-once |
Your approval releases the gate and the write runs. |
answerer returns rejected |
Your refusal stops it, and the model is told a human said no. |
| no answerer registered | Fail closed — ask is never less safe than deny. |
gateMode: 'deny' |
Unattended mode refuses and never even asks. |
If these pass and the browser still shows no panel, the problem is the
environment, not the plugin — check the session's permission preset (§Troubleshooting).
Full detail: docs/VERIFY-INTEGRATION.md.
| Symptom | Cause | Fix |
|---|---|---|
ERR_PNPM_ADDING_TO_ROOT |
Missing -w |
dsh plugin --profile fltest add -w file:... |
ERR_PNPM_NO_MATCHING_VERSION for @deepseek-ai/schemastery |
A workspace-internal version (0.1.5) was requested; only 3.18.x is published |
The package now pins ^3.18.2 — update if you forked it |
feature-loop absent from --dump-config |
Package not installed into this profile | Re-run step 3 against --profile fltest |
Row present but config: empty |
Patch layer not applied | Check your edit landed in ~/.dsh/profiles/fltest/cordis.patch.yml, not the repo's |
| Boot fails naming a spec dimension | validateSpec rejected it |
Add the named field; all eight are required |
irreversible tools ... with no termination guards |
You declared an irreversible tool with guards: [] |
Add guards: ["error-cascade", "tool-cycle"] |
| Nothing ever asks for review | Gate policies all auto, or no judge configured, or spec omitted |
edit: auto-if-confident; omit spec and nothing runs at all |
Gate asks with always-approve instead of your policy |
Your gatePolicies/actuator key is not a real harness tool name |
Use read/write/edit/bash/glob/grep, not write_file/edit_file |
| Tool error instead of an approval panel | The session's permission preset is danger-full-access, whose approval policy never rejects before any UI |
Select the workspace-write preset in the session, or run a private DSH_HOME with it pinned; see §2.5-2.6 of the runbook |
| Approval panel appears but the run then stops | You clicked Reject, or the request was cancelled | Expected. Approval is one-shot and per request; the step counts as a failure |
| Port 3081 already in use | You are already running the main GUI | Use --port 3099 |
rm -rf ~/.dsh/profiles/fltest /tmp/fl-demoRemoving the plugin from a profile you want to keep:
node "$DSH/apps/cli/lib/bin.js" plugin --profile <name> \
remove @freepeak/dsh-feature-loopThese are real and documented in docs/PRD.md. Setup does not fix them.
- Spend is not metered.
LoopBudget.spend()is never called with real usage, so the cost ceiling currently measures zero.maxStepsworks and is trustworthy;costBudgetUSDdoes not yet. Do not rely on it to bound money. - The plugin path has no automated test. Both bugs found during this
build — a detector history that was never populated (§7.1) and a fail-open
gate on the irreversible path (§7.2) — were caught by reading and manual
verification, not by CI.
src/plugin.tsis also excluded from the CI typecheck (it needs harness packages CI cannot resolve). - The review rate is ~20% in practice, not the book's <10%. Critical signals are deliberately not rate-limited — safety is not subject to an attention budget — so a run with an error cascade exceeds the target by design.
- The price table is illustrative.
mimo-v2.5runs on a subscription, so marginal cost is near zero; the rates exist so a ceiling has something to measure against. run_teststimeouts kill the direct child, not grandchildren.
# build
cd ~/work/harvey/freepeak/dsh-feature-loop && pnpm build
# install into a profile
node "$DSH/apps/cli/lib/bin.js" plugin --profile fltest add -w file:~/work/harvey/freepeak/dsh-feature-loop
# verify composition (cheapest check)
node "$DSH/apps/cli/lib/bin.js" --profile fltest --dump-config | grep -A14 -m1 "id: feature-loop"
# boot
node "$DSH/apps/cli/lib/bin.js" --profile fltest --port 3099 --no-open
# the standalone runner — same policies, no harness at all
bash demo/run.sh