Instructions for AI coding agents that work on this repository. Human contributors: see CONTRIBUTING.md, which has the same rules with more detail.
- Read LESSONS.md (open lessons are constraints).
- Read docs/code-standards.md (Verifier honesty, fences, merge gate).
- Keep the Hard rules below; never patch DSH or commit secrets.
One issue → one branch → one PR → CI tests-ok → auto-merge (squash).
A person talks to the Chief (the Main Bot). The Chief hands the work to the stationary roles — Triage, Dev, Reviewer — each a Bot in the same tree, and each step below names the role that does it. OpenCode and Codex are gone (owner decision 2026-10-22): nothing requests @codex review, nothing waits for a Codex review, and no model route is prescribed here.
An @-mention must produce one reply. The member takes the work, points to who has it in a line, or says it cannot; silence is a defect, not "nothing to add" — the round record already keeps passed and missed apart, and #913 tracks the summary that merges them.
-
Issue. Work one issue per session and per git worktree. Skim LESSONS and code-standards first. Security debt first: #9 → #10 → #8.
-
Branch from the live base, and prove it.
git fetch origin && git switch -c <branch> origin/main— never from a localmainyou did not just fetch. Prove the base before you write anything:git diff origin/main...HEAD --stat
That output must list only files you changed. Any file you did not touch means the branch sits on an older
main: delete the branch and start again from the fetched tip. Never stack the change on a stale base and never resolve the ghost files by hand. A stale base merges green and silently reverts what landed meanwhile: on #887 the branch restored an oldmainand removed the 84 test lines #869 had just merged, while the pull request page showed every check green (#864, #876, #887).Devthen writes a test that fails without the change, and fixes the class of bug. -
Verify locally:
npm run verifyat the repo root (Node 22+, see Commands). -
PR to
main. Imperative English title,Fixes #Nin the body, and what you ran and did not run at the tip SHA. A user-visible change adds one fragmentchangelog.d/<issue-or-slug>.<kind>.md(changelog.d/README.md); never editCHANGELOG.mdin a feature PR. Squash is the only merge method; the PR title and body become the commit message, so keep the body short. -
CI. The required check is
tests-ok. It is green only whentest,plugins (tree),plugins (social)andpackageall pass. Never merge around a red check. -
Gate, then queue. Five checks, in this order. Each one can refuse.
- Tier.
node .github/scripts/risk-gate.mjs --pr <n>, or quote therisk-tier:line of the job summary. T0/T1 need greentests-okat the tip, a mergeable pull request and design-conformance — no PASS. T2/T3 need a PASS at the tip SHA written by the Reviewer (review-pr: PASS / FAIL / INC), never by the implementer (Merge gates). Neither a PASS nor green CI replaces the other. - One conclusion per tip. A tip carries at most one
Verdict:block. A second conclusion at the same tip is an incident, not a better answer; a new commit voids the PASS; a correction readsCorrection from <Bot>: …and is not a verdict. - Enqueue.
node .github/scripts/enqueue-guard.mjs --pr <n>, thengh pr merge --auto --squash --match-head-commit <sha>. The guard re-reads the live head and refuses (exit 1) on ahold, on a newest conclusion that is not a PASS, and on a report marked "record only — do not merge", so a PASS is never a queue permit on its own (#602).mainrequires a merge queue: the command enqueues the pull request (gh may answerThe merge strategy for main is set by the merge queueoralready queued), the queue performs the squash merge, and a plain--squashmerge is refused — do not retry (merge plumbing). - Judge on the merge tree. Whether the pull request can merge is decided on the queue entry — the pull request merged onto the current
main, built and tested again — never on the pull request page. The page's green is the head alone: #759 was 9/9 green, was kicked out of the queue twice, and went red onmainatcore/test/follow-through.test.js:264. - Never bypass. No admin-merge, no bypass, no ruleset change. A
Docs-not-needed:line goes in the PR body as well as in the branch commit, and you check both at the tip (why, and how). RunverifywithTZ=UTC.
Exception: a mechanical release PR under the standing approval below.
fix-issuewalks all of this, with the environment gotchas. - Tier.
-
Stop and hand over to the maintainer on a security-sensitive change, an unresolved maintainer decision, or when you cannot get a green tip.
When a class of bug repeats, date a lesson in LESSONS.md (Seen N×); after ≥3 days promote the durable rule into docs/code-standards.md / CONTRIBUTING.
On 2026-10-19 07:02 (Asia/Shanghai) the maintainer, Danyang (GitHub Birfy), decided that FlyBot ships a real semver GitHub release daily (patch bump of the latest release, starting at v0.2.0, skipping a day with no changelog fragments), and grants a standing approval for the mechanical release PR, so the daily PR needs no fresh one-click approval. The approval is limited to a PR that changes only these files, and nothing else:
CHANGELOG.mdandchangelog.d/*core/package.jsonandcore/package-lock.jsonpatches/ds-bot/0007-version-and-tooling.patch
It does not waive the checks: the PR still needs green required checks (tests-ok) at its tip SHA and the daily preflight (no release for that version yet, the latest Test on main green, at least one changelog fragment). Anything beyond those files needs a fresh review (a PASS at the tip) like any other PR.
On 2026-10-21 the owner replaced the earlier "only a merge click in the UI is the approval" rule: a Bot that gates a PR itself may merge it into main itself, including T2 and T3. The gate is the review, not a person's click — tests-ok green at the tip, a PASS at that same tip, mergeable and design-conformant, through the merge queue, as the Birfy account. It never admin-merges, never force-pushes main, never bypasses a check, and never removes a PR the owner put in the merge queue.
Admission line — only two kinds of work get a PR at all:
- a fix for a reproducible real defect (with a minimal reproduction or an equivalent test), or
- something the routine work itself needs.
Such a PR needs tests-ok green at the tip and a test that fails when the source change is reverted. A new commit at the tip voids the PASS and the review is repeated.
One PR per issue. When an issue already has a pull request, keep working in that one: re-base its branch, use update-branch, or push a new commit to its head, and add the tests there too. Never open a second PR and close the first — that is what produced a trail of superseded PRs. A contributor's fork is treated the same way while it allows maintainer edits. The only exception is a branch that cannot be pushed (maintainer edits off, or the author is gone and the branch is unusable): then a new PR is allowed and the reason goes on the issue — never a silent swap.
Anything else is not opened, not queued and not reviewed: pure UX polish, new configuration keys, design proposals. Leave them in needs-human, or close them with the reason written down, and report them in one batch instead of one at a time.
Still report to the Chief first (who asks the owner): a vague scope, a change on the security surface (secrets, permissions, sharing), or a policy change the owner can feel. The routines keep their own bottom lines: no production deploy without consent, no touching the owner's own machine, never print a secret, never open an issue on someone else's upstream, never remove a PR the owner put in the merge queue.
core/ is the source of truth; patches/ds-bot/ (*.patch and files/) and core/client.js are generated from it. Never hand-merge them. The patch format is merge-friendly (no index lines, no counts that depend on other edits, new files stored whole in files/), so two PRs that touch different parts of one core file usually merge without a conflict in patches/; the steps below are for when they do not.
git fetch origin && git merge origin/main # or rebase
# 1. Resolve conflicts in real sources by hand (core/src/**, core/index.js, tests, docs).
# 2. Take main's version of the generated patch files (generate rewrites patches/ds-bot/ entirely):
git checkout origin/main -- patches/ds-bot
# 3. Regenerate from the merged core/, then check:
(cd core && npm run build) # client.js, carried by patch 0008
node scripts/ds-bot-patches.mjs generate
node scripts/ds-bot-patches.mjs check
npm run verifyIf topics.json conflicts, keep both sides' entries (one patch per edited upstream file; a new file needs none).
Decided by the owner (Danyang, GitHub Birfy) on 2026-10-18/19; what runs today is two routines on a timer and the rest in rounds. A routine has one job and does only that job. Every write a routine makes carries the Written-by: line (rule).
| Routine | Runs | Job |
|---|---|---|
| Product self-test | timer, 09:00 and 21:00 Asia/Shanghai | Uses FlyBot and Bot Social as a user and only files issues: no PRs, no pushed branches, no code changes. Each issue states the expected behavior and the change scope. Search for duplicates first. |
| Repo maintenance | a round in the delivery group, no timer | The only routine that opens fix PRs. Each run: review open PRs against the design rules; triage every open issue (fixed, duplicate or obsolete: close with a comment; still valid: readiness check below); label every open issue and PR; develop only agent-ready issues; merge ready PRs through the merge queue within the gates below. Never deploys. Never edits CHANGELOG.md or a version number. |
| Daily release | timer, about 02:00 Asia/Shanghai | Publishes a real semver GitHub release (tag = core/package.json version, patch bump per day, skipped when there are no changelog fragments) through a mechanical release PR. It owns CHANGELOG.md and the version. The files and the approval are in Standing approval. The fork's release is followed by the upstream fast-forward and upstream's own release, in one window per version: docs/release-runbook.md. |
Only the two rows marked timer self-trigger. Maintenance, triage and review run as rounds in the delivery group — Triage lists what is open, Dev claims one item and pushes a tip, Reviewer judges that tip, the Bot that gated it enqueues — and hold no cron entry. So a quiet period means no round ran, not that a routine is broken. A timer belongs to a Session, not to a Bot (#628): read bot_schedules before saying anything about which timers a Bot has.
One read of the open state per round — issues, pull requests, checks — is the budget, not a step to repeat. Whoever reads it writes the facts down where the next Bot will look: the pull request body or a comment, as text. Nobody else re-pulls the same list, restates it in a group chat, or pastes more than five lines of a log; they read the comment. A second opinion needs a second question, not a second fetch. #796 measures what a round actually costs.
schedule_list answers for the session the call runs in, not for the Bot. A schedule the owner created in another session of the same Bot is simply absent from the answer, and nothing in the response says which session it answered for — so [] is indistinguishable from "nothing is scheduled". This cost two wrong conclusions on 2026-10-21: a Bot reported "0 timers" and the team concluded a live schedule had never existed, and another run came close to deleting and recreating a schedule that was running.
An empty schedule_list is not evidence that a schedule does not exist; never delete or recreate a schedule from it, and never report "no timers" as a finding. The owner's UI shows the real list; ask the owner instead of the tool when the run depends on it. schedule_delete and schedule_update share the same scope, so a Bot can otherwise "clean up" a schedule it cannot see. The DSH-side fix is tracked in #628.
bot_schedules (no arguments) is the Bot-wide list. It reads DSH's host-wide catalog and keeps every Session that speaks for the calling Bot — its own chat, the earlier parts of it, the group chats it speaks in, and a group chat's own Session for the tasks the owner set for that group (to its members only) — plus its paused tasks, with each task's id, title, next run, state and owning Session. Read that tool before saying anything about this Bot's timers; DSH's schedule_list stays per Session, and the DSH-side scoping fix is still tracked in #628.
- Type (exactly one):
bug,enhancement,documentation,accessibility. - Area (one or more):
area:social,area:ui,area:automation,area:release,area:security,area:docs. - Source:
self-test. - State:
needs-info(spec unclear),needs-human(needs a design decision from the owner; the comment says what),agent-ready,agent-working,agent-pr,hold. - Controller-managed, never set by hand:
risk:T0torisk:T3,agent-task,status:done. - A
risk:T*label is advisory, and never the tier a decision uses. The controller is a poll: GitHub delays scheduled runs by hours (2.5–6 h spacing observed on this repository, #629), and a fork PR'spull_requestrun has a read-only token, so it cannot label one at all. An open PR can therefore carry no tier until the next run, and a PR opened and merged between two runs never gets one. Read a missing label as "not polled yet" — never as T0/T1 and never as a reason to block. The tier that decides is the onemerge-gaterecomputes from the diff at merge time, andrisk-tier.ymlreports the same tier on every PR. - The computed tier is readable from the job summary.
risk-tier.ymlends its summary with one machine-readable line —risk-tier: T3; basis: AGENTS.md=T3, …— built by the samejudge()call that prints the report, so the line and the prose cannot disagree. Quote that line instead of guessing a tier. The workflow itself never writes a label, and no review transcribes a tier onto a PR: the controller copies it on its next poll (#609). holdmeans automation leaves the item alone.
Label agent-ready, with a triage comment, only when all three hold:
- Every design conflict is resolved (owner design rules, recorded owner decisions, RFC/decision issues, other open issues and PRs), with no open question for the owner.
- The expected behavior and the change scope are clear.
- It is not
hold, not a tracking/umbrella issue, and not team-via-groups work (that waits for the owner's go-ahead).
Otherwise label needs-human or needs-info and name the blocking conflict or question in a comment. Agents develop only agent-ready issues.
While checking labels and state, a write that carries no Written-by: line gets one comment naming it; the audit never repairs someone else's text by editing it (rule).
- Before work: skip the issue if someone else is assigned. Otherwise assign it to
Birfy, addagent-working, and post a claim comment that names the branch. - Stopping without a PR: unassign, remove
agent-working, and comment why. - The PR says
Fixes #N, is assigned toBirfyand labeledagent-pr.
The owner kept one shared GitHub identity for every Bot (2026-10-21), so attribution is text and only text. Every state-changing write — an issue comment, a PR body, an issue body, a merge-queue note — carries Written-by: <Bot name> as its first line, e.g. Written-by: Reviewer. A conclusion is the one exception, because parseReport reads the first non-empty line as the verdict (#637): its first line is Verdict: … at <sha>, Written-by: <Bot> is the second line, and a relay adds Posted-by: <Bot> third without changing anything else. A scheduled routine quotes this rule and does not restate it.
The gate refuses what it can check, and fails closed. review-gate.mjs — and merge-gate.mjs, which imports its decide, so a queue entry is judged by the same rule — refuses a report that carries no Written-by: line, and refuses one whose name is not in the policy's writers list. Two separate refusals, so the log says which defect fired. Triage names a write that carries no such line in one comment and never repairs it by editing someone else's text.
The gate reads a conclusion only from someone GitHub ties to this repository: the comment's author_association must be OWNER, MEMBER or COLLABORATOR, or it is not a conclusion and counts for nothing — otherwise any passer-by could hold a merge with one Verdict: INC line, because the newest conclusion decides (#676). An association the API did not send is not assumed.
What this buys is presence and a name from a closed list, not truth: with one shared identity the line is a self-report that can be left out (refused) and can name the wrong Bot (undetectable here). The write layer knowing the writer is the DSH-side half, #633.
- T0 / T1: merge through the merge queue once
tests-okis green at the tip, the PR is mergeable and design-conformant. No PASS is required. - T2 / T3: a PASS at the head commit from a reviewer in
.github/agent/review-policy.json, with green CI, and the agent that gated it then enqueues it itself (self-gate). T2 and T3 need no maintainer approve (user decision 2026-10-22): no humanAPPROVEDreview is read or required, andmerge-gatere-judges the head on the queue entry. Self-PASS across Bots counts: a PR opened by the owner's Bot/agent (Birfy) may be PASSed by the owner's own reviewer Bot/agent (the policy'sselfReviewlist) — the shared account is why that list exists, not a licence for an implementer to review its own pull request. Neither the PASS nor green CI replaces the other. - A PASS must list what it ran. The
Ran:line (or aRan …:heading over$commands) carrying at least one real command is part of the gate, not a nicety:merge-gaterefuses a PASS that lists none (#681, #574). When a PASS at a tip cannot be read for that reason, the reviewer may post one replacement PASS at the same tip: its body keeps the original conclusion, adds theRan:lines of what was actually run, and carriesReplaces: the PASS at <sha> merge-gate rejected for having no Ran: line. That is a replacement, not a second conclusion — apart from it, one tip still carries one. - Any new commit voids a PASS. CI green, the merge queue and the no-bypass rule apply to every tier.
- Agents never bypass a gate and never admin-merge (the routines run as the maintainer account
Birfy, but use it only for the actions above, never for admin merge, bypass or changing protection rules). RunverifywithTZ=UTC.
T2 and T3 take one path into main: a PASS at the tip that lists the commands it ran, every required check green at that tip, and merge-gate re-judging the pull request head when the entry reaches the queue. T0 and T1 take the same path without the PASS. The queue is the only route in — merge-gate is a required check of the main merge queue ruleset, so a ref push that never built a queue entry cannot land.
- T2 and T3 need no maintainer approve (user decision 2026-10-22 07:15Z). A human
APPROVEDreview is not one of the gates and nothing reads one; the tier decides how closely a change is reviewed, not who signs it off. - No implementer writes the Verdict. On the API side self-review cannot be told apart mechanically — every pull request is opened by the shared
Birfyaccount, andselfReview: ["Birfy"]inreview-policy.jsonmakes a PASS always look like Birfy judging Birfy — so what the team holds is the role split: the implementer Bot does not write the Verdict, the Bot that writes the Verdict neither changes the tip nor re-judges its own verdict, andselfReviewstays as it is because clearing it would leave every pull request without a conclusion. holdis human-only. Only a person sets the label and only a person lifts it; automation refuses a held pull request and never removes the label itself. That is the one human input left on the merge path.- The label awaiting-approval is retired. A PASS at the tip goes straight to the queue through the enqueue guard (#687); no pull request waits for an approval click and the label is not set again.
- A pull request enters the queue with its issue and its
agent-prlabel. On a branch this repository owns, the enqueue guard also refuses one whose body names no issue (Fixes #<n>/Closes #<n>) and one without theagent-prlabel (#718), so "one issue, one pull request" is checked rather than remembered. Two exemptions: a mechanical release PR, which carries neither by design, and a fork (pull.head.repo.full_nameis not this repository), whose head we cannot push to and where the contributor keeps their own workflow.
On the code fork Birfy/FlyBot, where this team's pull requests actually merge, the gate is a ruleset, not a queue (#812). Once the ruleset from --apply is on that repository, its main requires two checks, tests-ok and enqueue-guard, with bypass_actors: [], so PUT /pulls/<n>/merge is refused — 405/422 — while the head carries a hold, no PASS naming that exact tip, a PASS that lists no Ran:, no agent-pr, or a body naming no issue. The required check is written by enqueue-guard.yml, from code taken from main and never from the pull request, on every event that can change the answer for a fixed head: a push, a label added or removed, a comment, a review body, or a manual run. It calls enqueue-guard.mjs's own decision — the same function that prints the command — so the check and the command cannot disagree; a new commit is a new head and a new check run, and a green check never carries over. The ruleset is merge-gate-ruleset.json: node .github/scripts/gate-ruleset.mjs --check reads the live state and writes nothing, while --apply and --rollback change repository protection and are the owner's call, not a Bot's. The order is not optional: the check must already report on the open pull requests before the ruleset goes on, or every one of them reads "Expected — waiting for status" and cannot merge.
Four rules decide what a queue entry must pass. The mechanical judge runs where the merge actually happens: merge-gate.yml fails the merge-gate job on a merge_group entry, and merge-gate.mjs does the judging.
holdblocks mechanically. A pull request labelledholdfailsmerge-gateon its queue entry. The label is only a marker; the job is the gate, and it is a required check of themain merge queueruleset (#639, set 2026-10-22), so the refusal holds the entry.- Labelling a pull request
holdmeans adding a record. Add the label and post a new comment saying Record only — do not merge / 仅记录、禁止入队. The pair is what holds it. Add that comment; never edit or delete an earlier PASS report to change what it said — the history is the record, and rewriting it is the thing that reads as an incident afterwards. - An authorisation is not a queue permit. The owner or the Chief saying "let it through", "claim it" or "handle it along with the rest" licenses no specific pull request for the queue. Entering the queue is an explicit action on that one pull request, after its own gate passed, while it carries no
hold. #566 is the case this exists for: "handle it along with the rest" was read as a permit, the tip was PASSed at 09:48:18Z, enqueued at 09:48:28Z and merged at 09:50:40Z asabaf938b. - The newest conclusion at the head being merged decides. Read the pull request's comments and review bodies, live, for the exact head SHA being merged — the pull request's own head, on a queue entry too (the
<sha>ingh-readonly-queue/main/pr-<N>-<sha>is the entry's base, not its head; #654) — and take the newest text that carriesVerdict:or a## Review —heading: INC or REQUEST_CHANGES refuses, PASS allows, no conclusion refuses too, and so does a report with noWritten-by:line or one that names a writer the policy does not list (#627). Since #637 the format is fixed: the first line of the conclusion is exactlyVerdict: PASS at <sha>/Verdict: INC at <sha>/Verdict: REQUEST_CHANGES at <sha>, with the attribution on the second line (Written-by: Reviewer, and a relay addsPosted-by: Chief (proxy…); the older proseWritten by: …reads the same way) and the evidence below it — a body that carries a verdict anywhere else is no conclusion, and no conclusion refuses. A result frompull_requesttime never stands in — on #610 the tip report ran at 10:31:08Z and the INC landed at 11:24:37Z, 54 minutes later. The gating agent checks this before enqueueing;merge-gate.ymlchecks it again atmerge_group, and the stricter answer wins. That job runs on two events: atmerge_groupit judges and a refusal fails it, and atpull_requestit only reports what the queue entry would refuse — a required check that never reports on the pull request head leaves every pull request waiting for a status that never comes and unable to enter the queue at all (#639). The required status check is the job namemerge-gate; the workflow nameMerge gateis not a context any run produces.
A review's state is not read at all: approvals are not a gate any more (user decision 2026-10-22), and the account cannot approve its own pull requests, so its reviews stay COMMENTED even for an INC — the INC on #610 was a comment rather than a review.
One conclusion per tip. A tip carries at most one conclusion: one Verdict: block naming one SHA. Two conclusions at the same tip is an incident, not a tie to be broken — nobody re-verdicts to win, the Reviewer that wrote the later one retracts it in a comment, and the pull request waits. A correction reads Correction from <Bot>: … and carries no Verdict: line, so it never competes with the standing conclusion; the replacement PASS for a missing Ran: (above) is the only other body allowed at one tip.
Judged on the merge tree, never on the pull request page. The page answers "does this commit pass alone"; the queue answers "does this commit pass merged onto the current main", and the second one is the merge decision. #759 was green on every check, was kicked out of the queue twice, and went red on main at core/test/follow-through.test.js:264. A red queue entry that names files you did not touch is the stale base of step 2, not bad luck: fix the base, do not re-enqueue blindly.
A merged pull request's body is not a control. GitHub evaluates Fixes #N once, when the merge lands. Editing the body of a merged pull request afterwards closes nothing and triggers no guard; it only makes the record disagree with what merged. If the issue is still open, close it with a comment naming the pull request — and if the merge itself was wrong, say so on the issue.
This is not a new rule, and it does not fail because it is unclear — it fails when nobody reads it at the tip. What the two places are for:
check-arch-docs.mjsreadsgit log <base>..HEAD --pretty=%B— the branch commit messages, plus the pull request title and body on the queue entry.- The merge queue squashes the pull request title and body into the one commit that lands on
main, so that commit carries the body and none of the branch messages.arch-docsruns again on the push tomain. - A trailer written only into a branch commit is therefore gone exactly when
mainis judged.
Self-check before you push, and again after any new commit: the same line appears in both places.
git log -1 --format=%B # the branch commit message
gh pr view <n> --json body --jq .body # the pull request bodyIf either lacks it, add it there and push. No trailer and no subsystem-doc update fails arch-docs on the branch; a trailer in the wrong place fails only after the squash, when the branch is gone.
The T0/T1 exemption is computed, never labelled. merge-gate.mjs runs the base copy of risk-gate.mjs over the changed files and .github/agent/risk-rules.json to get the tier; a risk:T0/risk:T1 label does nothing. Every Bot shares one account and a label carries no signature (#627), so a label-based exemption would let whoever can label turn a T3 into "no review needed" — the gate itself. A mechanical release PR is recognised by its files as before, and hold still refuses whatever the tier.
The standing "file no new issues" rule covers design proposals only: a reproducible code defect is still filed as an issue, with its reproduction.
- The self-test routine files issues only; the maintenance routine opens PRs; the release routine owns
CHANGELOG.mdand the version. - No deploy of production Bot Social, and no change to the owner's local instances, without the owner's explicit yes.
- A routine PR is merged by the Bot that gated it (owner decision 2026-10-21, self-gate). A typed chat reply is still not an approval for anything else, and an owner-only decision (scope, security surface, policy) still waits for the owner's word through the normal path (#349).
- Connector tools are on for every turn of the owner's own Bots, whoever started it, unless the owner turns a connector off for one Bot; a friend's Bot never gets them (#359, owner decision 2026-10-22 on #601). For GitHub work a Bot uses the GitHub connector tools first and
$DSH_SECRET_GITHUB_TOKENwithgh/gitonly as the fallback (for examplegit push). - Bot Social has no keyword commitment scan.
- A schedule is per session, not per Bot: an empty
schedule_listis not evidence that a schedule does not exist; never delete or recreate a schedule from it, and never report "no timers" as a finding;bot_schedulesis the Bot-wide list for this (see A schedule is per session).
FlyBot is a team of long-lived Bots, built on DeepSeek Harness (DSH). It has two parts:
ds-bot/: pristine upstream ds-bot (Fei2411/DeepSeek-Bot), pinned inds-bot.pin.json. Never edit it.patches/ds-bot/: FlyBot's ordered patches on it; seepatches/ds-bot/README.md.core/:ds-bot/+ the patches (committed, what ships and what tests run on): the Bot features, written as a DSH plugin. The plugin runs in DSH Desktop, in DSH Web and in the desktop app.plugins/social/,plugins/tree/,plugins/timeline/: peer plugins@flybot/social,@flybot/treeand@flybot/timeline.
The Bot team:
- Every Bot is a DSH Session and runs through the normal DSH agent loop, tools and model routes.
- The team works together: Bots message each other, share group chats and can see what the other Bots are doing. Each Bot still runs on its own and does not disturb the others.
- Bots have clear, different roles. A Main Bot has the highest rights: it creates and changes other Bots, and can stop a running turn.
stop_botends that turn; it does not remove the Bot. Deleting a Bot is the person's action in the UI (delete-bot), and #638 tracks whether a Bot may retire one instead. Other Bots do not get those management rights. There can be more than one Main Bot. - Tree child creation (#16, Option B). A tree Bot (not only the Main/root) may call
create_child_botto create a child directly under itself. The tool takes no parent argument;maxChildrenand depth limits stay in force. The new child is usable only by its creator's owner; sharing it with another person still requires an independent per-Bot grant (default deny). There is no publish mechanism. See docs/subsystems/tree.md. - The multi-human authority contract (human owner ↔ designated Chief ↔ specialists) is documented in docs/subsystems/team.md as planned, not shipped: team mode stays disabled until its authorization gates pass. Issue #16 is resolved (Option B); do not re-open Main-only child creation.
- Users customize each Bot (name, role label, instructions, model, character).
- Behavior must be reliable: a Bot acts on evidence, stays inside its goal, and its results can be checked.
- Build on DSH; never patch it. Bot features use the documented plugin APIs. The desktop app starts DSH only through its documented entry points (
@deepseek-ai/dsh/profile-boot, thedshCLI). Never patch DeepSeek Harness or depend on files inside a DSH install. If something is missing in DSH, stop and say so; do not work around it with a patch. - No secrets. Never write, print or commit API keys, tokens, cookies or
DSH_HOMEcontents. Use the mock model (core/dev/mock-llm.mjs) for tests; it needs no key. Every one of the owner's own Bots gets the owner's saved keys on every turn (owner decision 2026-10-22, #601); a key a shell cannot use is named inDSH_SECRETS_WITHHELD(NAME:reason, never a value:remote-bot,not-in-scope,unknown-bot): read the reason instead of assuming the owner did not configure it. - Only original material. Do not copy code, text, icons, logos or graphics from other products or websites.
- The core is ds-bot + patches. Change
core/, then runnode scripts/ds-bot-patches.mjs generateandcheck, and commitcore/andpatches/together. CI fails ifds-bot/differs from the pin orcore/differs fromds-bot/+ patches. client.jsis generated. Changecore/src/client/, then runnpm run buildincore/and commit both files. Never editclient.jsby hand.- Do not touch the user's own DSH data. The test bench keeps its data in
.dsh-test-<name>/. Never point a test at~/.dshor at a DSH that the user runs.
Node 22 or newer is required. On Node 20 the tests fail with "Could not find test/*.test.js". CI uses Node 24; on the shared box use PATH=/workspace/tools/node24/bin:$PATH.
One command, from the repo root: npm run verify runs what CI runs, in CI's order: core lint (oxlint + arch-docs), plugin lint, build-client.mjs --check, ds-bot-patches.mjs check, the core suite and both plugin suites. It needs the one-time setup below (npm ci and npm run harness in core/). It does not run the pack check, typecheck or actionlint of the CI package job, and not the smoke test.
Sandbox preflight (#607, #615). A session on workspace-write (or read-only) on a host without a usable sandbox backend refuses every bash call with SANDBOX_UNAVAILABLE and looks merely stuck. Run node .github/scripts/sandbox-preflight.mjs --mode <your file policy> once at the start: exit 0 usable, 3 proven blocked, 4 cannot tell, 2 usage. Pass --mode (or set FLYBOT_SANDBOX_MODE) - the preflight cannot see the session policy and must not guess it; danger-full-access needs no backend at all and is never reported blocked. On 3, report one line (blocked: no usable sandbox backend plus the unlock it prints) and do not retry shell calls. On 4, do not stop: a bash call refused with SANDBOX_UNAVAILABLE is the only proof. File tools and the GitHub API still work either way; the tiers are in docs/sandbox.md.
The rest of this section runs in core/. The unit tests and the linter load the @deepseek-ai/* packages from dev/harness/, which npm run harness installs from npm at the DSH version in dev/harness/package.json. DSH_HARNESS points them at another install or at a DeepSeek Harness checkout instead. The test bench needs a checkout at ../deepseek-harness (or DSH_HARNESS), at the DSH version in the README.
npm ci # build tool only (esbuild)
npm run harness # once: the DSH packages for the tests and the linter
npm run build # src/client → client.js
npm test # unit tests; also fail if client.js is stale
node scripts/build-client.mjs --check # client.js matches src/client
npm run lint # oxlint
CONTEXT_WINDOW=100000 CHECK_RATIO=0.45 dev/bench.sh smoke 3096 8796 reset && node dev/smoke.mjs smoke 3096 # smoke test
dev/bench.sh smoke 3096 8796 stopA change is done when npm run verify passes (or npm test, the --check and npm run lint in core/); CI runs them on every pull request. Run the smoke test as well for changes to index.js or src/host/. Use a free port for each bench, and stop every bench and process that you started.
After oxlint, npm run lint also runs npm run arch-docs: if an owned file (see docs/subsystems/) changes without its subsystem doc in the same PR, the check fails—update the doc or add a Docs-not-needed: trailer.
core/index.jsandcore/src/host/: the server side (roster and store, tools, turns and delivery, groups, conversation parts and the search index, images, the browser bridge, theapi/botchannel).core/src/client/: the browser side (sidebar, chat, panels, themes, characters, the Bot browser panel).core/web-search.js: theautoweb-search provider, independent of the Bot team code.core/test/: unit tests and fixtures.core/dev/: the DSH packages for the tests (harness/), mock model, test bench, smoke test, character generator, Desktop scripts for Linux.plugins/social/,plugins/tree/andplugins/timeline/: peer plugins@flybot/social,@flybot/treeand@flybot/timeline, not undercore/.- Architecture and ownership: docs/architecture.md and docs/subsystems/.
- Follow the code around your change. Keep functions small and names plain.
- Write a comment only when the reason is not clear from the code: a hidden constraint, an invariant, a workaround. Do not describe what the code does.
- Commit messages in English, imperative subject, the reason in the body. Keep commits small.
- User-facing text in the plugin is English. Keep the README and README.zh.md in step when you change one of them.