@detergent-software/conductor 3.0.0-dev.9 → 3.1.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +486 -0
- package/README.md +272 -126
- package/build/brief-cli.js +5 -12
- package/build/brief-cli.js.map +1 -1
- package/build/brief.js +4 -3
- package/build/brief.js.map +1 -1
- package/build/broker-cli.js +41 -14
- package/build/broker-cli.js.map +1 -1
- package/build/broker.js +1 -1
- package/build/broker.js.map +1 -1
- package/build/cli.js +226 -64
- package/build/cli.js.map +1 -1
- package/build/clones.js +92 -0
- package/build/clones.js.map +1 -0
- package/build/code-host-cli.js +172 -40
- package/build/code-host-cli.js.map +1 -1
- package/build/code-host-service.js +35 -2
- package/build/code-host-service.js.map +1 -1
- package/build/code-host.js +138 -16
- package/build/code-host.js.map +1 -1
- package/build/conditions.js +33 -16
- package/build/conditions.js.map +1 -1
- package/build/config.js +58 -28
- package/build/config.js.map +1 -1
- package/build/console.js +17 -27
- package/build/console.js.map +1 -1
- package/build/credentials.js +86 -54
- package/build/credentials.js.map +1 -1
- package/build/daemon.js +54 -65
- package/build/daemon.js.map +1 -1
- package/build/file-lock.js +155 -40
- package/build/file-lock.js.map +1 -1
- package/build/follow-ups.js +2 -2
- package/build/follow-ups.js.map +1 -1
- package/build/help.js +242 -83
- package/build/help.js.map +1 -1
- package/build/history.js +10 -4
- package/build/history.js.map +1 -1
- package/build/host/attach-clients.js +56 -0
- package/build/host/attach-clients.js.map +1 -0
- package/build/host/credential-manager.js +109 -0
- package/build/host/credential-manager.js.map +1 -0
- package/build/host/keychain.js +44 -0
- package/build/host/keychain.js.map +1 -0
- package/build/host/launchd.js +118 -0
- package/build/host/launchd.js.map +1 -0
- package/build/host/libsecret.js +40 -0
- package/build/host/libsecret.js.map +1 -0
- package/build/host/macos-terminal.js +63 -0
- package/build/host/macos-terminal.js.map +1 -0
- package/build/host/platform.js +4 -0
- package/build/host/platform.js.map +1 -0
- package/build/host/secrets.js +46 -0
- package/build/host/secrets.js.map +1 -0
- package/build/host/shell.js +26 -0
- package/build/host/shell.js.map +1 -0
- package/build/host/startup.js +26 -0
- package/build/host/startup.js.map +1 -0
- package/build/host/systemd.js +91 -0
- package/build/host/systemd.js.map +1 -0
- package/build/host/task-scheduler.js +78 -0
- package/build/host/task-scheduler.js.map +1 -0
- package/build/host/terminal.js +47 -0
- package/build/host/terminal.js.map +1 -0
- package/build/host/tmux.js +39 -0
- package/build/host/tmux.js.map +1 -0
- package/build/host/windows-terminal.js +46 -0
- package/build/host/windows-terminal.js.map +1 -0
- package/build/in-process.js +5 -4
- package/build/in-process.js.map +1 -1
- package/build/inbox.js +47 -35
- package/build/inbox.js.map +1 -1
- package/build/init.js +509 -0
- package/build/init.js.map +1 -0
- package/build/landing.js +107 -10
- package/build/landing.js.map +1 -1
- package/build/lib/providers/ado/code-host.js +5 -1
- package/build/lib/providers/ado/code-host.js.map +1 -1
- package/build/lib/providers/ado/pipelines.js +138 -26
- package/build/lib/providers/ado/pipelines.js.map +1 -1
- package/build/lib/providers/ado/trigger.js +5 -0
- package/build/lib/providers/ado/trigger.js.map +1 -1
- package/build/lib/providers/ado/writes.js +17 -2
- package/build/lib/providers/ado/writes.js.map +1 -1
- package/build/lib/providers/github/actions.js +90 -3
- package/build/lib/providers/github/actions.js.map +1 -1
- package/build/lib/providers/github/code-host.js +2 -0
- package/build/lib/providers/github/code-host.js.map +1 -1
- package/build/lib/providers/github/writes.js +25 -2
- package/build/lib/providers/github/writes.js.map +1 -1
- package/build/lib/providers/index.js +3 -2
- package/build/lib/providers/index.js.map +1 -1
- package/build/lib/providers/npm/stage.js +14 -1
- package/build/lib/providers/npm/stage.js.map +1 -1
- package/build/lib/providers/timing.js +10 -0
- package/build/lib/providers/timing.js.map +1 -0
- package/build/lib/providers/verdicts/pr.js +47 -6
- package/build/lib/providers/verdicts/pr.js.map +1 -1
- package/build/lib/providers/verdicts/runs.js +12 -2
- package/build/lib/providers/verdicts/runs.js.map +1 -1
- package/build/logins.js +13 -6
- package/build/logins.js.map +1 -1
- package/build/manager.js +200 -55
- package/build/manager.js.map +1 -1
- package/build/note.js +5 -15
- package/build/note.js.map +1 -1
- package/build/notes.js +130 -23
- package/build/notes.js.map +1 -1
- package/build/notify.js +104 -42
- package/build/notify.js.map +1 -1
- package/build/npm-reads.js +151 -22
- package/build/npm-reads.js.map +1 -1
- package/build/ownership.js +2 -2
- package/build/paths.js +44 -1
- package/build/paths.js.map +1 -1
- package/build/plugin.js +4 -2
- package/build/plugin.js.map +1 -1
- package/build/profile-fields.js +153 -0
- package/build/profile-fields.js.map +1 -0
- package/build/profile-layers.js +264 -0
- package/build/profile-layers.js.map +1 -0
- package/build/profile.js +69 -49
- package/build/profile.js.map +1 -1
- package/build/project-check-cli.js +17 -19
- package/build/project-check-cli.js.map +1 -1
- package/build/project-check.js +166 -104
- package/build/project-check.js.map +1 -1
- package/build/protocol.js +5 -21
- package/build/protocol.js.map +1 -1
- package/build/pushover.js +5 -5
- package/build/pushover.js.map +1 -1
- package/build/queue.js +4 -4
- package/build/queue.js.map +1 -1
- package/build/registry.js +195 -36
- package/build/registry.js.map +1 -1
- package/build/release-kinds.js +34 -0
- package/build/release-kinds.js.map +1 -0
- package/build/rule-actions.js +8 -6
- package/build/rule-actions.js.map +1 -1
- package/build/rules-service.js +83 -44
- package/build/rules-service.js.map +1 -1
- package/build/rules.js +23 -23
- package/build/rules.js.map +1 -1
- package/build/run.js +20 -7
- package/build/run.js.map +1 -1
- package/build/runs.js +218 -68
- package/build/runs.js.map +1 -1
- package/build/scheduler.js +78 -12
- package/build/scheduler.js.map +1 -1
- package/build/search.js +2 -1
- package/build/search.js.map +1 -1
- package/build/server.js +51 -14
- package/build/server.js.map +1 -1
- package/build/spend.js +1 -1
- package/build/spend.js.map +1 -1
- package/build/store.js +79 -20
- package/build/store.js.map +1 -1
- package/build/supervise.js +1 -1
- package/build/supervise.js.map +1 -1
- package/build/timeline.js +104 -0
- package/build/timeline.js.map +1 -0
- package/build/tracker-reads-cli.js +4 -3
- package/build/tracker-reads-cli.js.map +1 -1
- package/build/trackers.js +56 -10
- package/build/trackers.js.map +1 -1
- package/build/train-cli.js +6 -5
- package/build/train-cli.js.map +1 -1
- package/build/train.js +2 -2
- package/build/train.js.map +1 -1
- package/build/upgrade.js +8 -9
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +10 -10
- package/build/wait.js.map +1 -1
- package/build/watchers/ado.js +42 -19
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/github.js +18 -4
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +2 -2
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-queue.js +47 -14
- package/build/watchers/linear-queue.js.map +1 -1
- package/build/watchers/linear-writes.js +26 -12
- package/build/watchers/linear-writes.js.map +1 -1
- package/build/watchers/linear.js +2 -2
- package/build/watchers/linear.js.map +1 -1
- package/build/watchers/source.js +5 -1
- package/build/watchers/source.js.map +1 -1
- package/build/webhook.js +80 -0
- package/build/webhook.js.map +1 -0
- package/build/why.js +1 -1
- package/build/why.js.map +1 -1
- package/build/worktrees.js +56 -93
- package/build/worktrees.js.map +1 -1
- package/package.json +61 -61
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/model.ts +55 -6
- package/plugin/hooks/register.tsx +43 -4
- package/plugin/skills/conductor-console/SKILL.md +26 -27
- package/plugin/skills/work-item/SKILL.md +58 -54
- package/plugin/skills/work-item/trackers/ado-boards.md +2 -2
- package/plugin/skills/work-item/trackers/github-issues.md +1 -1
- package/plugin/skills/work-item/trackers/linear.md +2 -2
- package/plugin/types/index.d.ts +1 -1
- package/protocol/PROTOCOL.md +3 -9
- package/build/checkout-sync.js +0 -249
- package/build/checkout-sync.js.map +0 -1
- package/build/credential.js +0 -66
- package/build/credential.js.map +0 -1
- package/build/startup.js +0 -92
- package/build/startup.js.map +0 -1
- package/build/tab.js +0 -32
- package/build/tab.js.map +0 -1
package/README.md
CHANGED
|
@@ -10,24 +10,30 @@ conduct start start the daemon detached and wait until it an
|
|
|
10
10
|
conduct stop stop the running daemon
|
|
11
11
|
conduct supervise [--fail-fast] run the daemon and restart it when it dies (see Restarts)
|
|
12
12
|
conduct status [--json] the daemon's pid, port, projects, workers and held locks
|
|
13
|
-
conduct
|
|
13
|
+
conduct init [<path>] [--name <name>] [--base <branch>] [--tracker <kind>] [--key <KEY>] [--team <key>]
|
|
14
|
+
[--query <query>] [--shared] [--yes]
|
|
15
|
+
onboard a repo: a starter profile, then project add and check (see Projects)
|
|
16
|
+
conduct project add <url | path> [--name <name>]
|
|
17
|
+
clone the project's remote into ~/.conductor/projects/<name>/repo and register it
|
|
14
18
|
conduct project list [--json] the registered projects
|
|
15
19
|
conduct project remove <name> drop a registration (a renamed project's old name), resolving its inbox items
|
|
16
20
|
conduct project check <name> [--no-worktree] [--json]
|
|
17
21
|
whether the project can be conducted, before a launch finds out
|
|
18
|
-
conduct
|
|
22
|
+
conduct profile <project> [--json]
|
|
23
|
+
the project's profile, machine over repo, each field with its source (see Projects)
|
|
24
|
+
conduct launch <item> start a worker: worktree and background session (a tab with workers.tab)
|
|
19
25
|
[--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
|
|
20
26
|
conduct retire <worker> [--force] stop a worker, remove its worktree and session
|
|
21
27
|
conduct attach <worker> wake a parked worker (no event text) and open it in a tab
|
|
22
28
|
conduct inbox [--json] what waits on you, with its commands and links (see Inbox)
|
|
23
29
|
conduct approve <item> [--text <text>]
|
|
24
30
|
approve a plan: post "approve" on the item as you, wake its worker
|
|
25
|
-
conduct revise <item> <text> ask for another plan: post "revise: <text>", wake its worker
|
|
31
|
+
conduct revise <item> <text> ask for another plan, waiting or approved: post "revise: <text>", wake its worker
|
|
26
32
|
conduct done <id> mark an inbox item done (a physical action, or one you handled)
|
|
27
33
|
conduct notify [--json] the notification profile, quiet hours, each channel's state
|
|
28
34
|
conduct notify profile [<name>] [--until <when>]
|
|
29
35
|
switch where pushes go (see Notifications)
|
|
30
|
-
conduct notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
|
|
36
|
+
conduct notify test [--kind <kind>] [--channel remote-control|pushover|webhook] [--raise]
|
|
31
37
|
push a sample item now, or raise a real one with --raise
|
|
32
38
|
conduct queue [--json] [--fresh] what the scheduler picks next and why the rest wait (see Scheduler)
|
|
33
39
|
conduct pause [<project>] [--until <when>]
|
|
@@ -38,7 +44,7 @@ conduct focus <project> [--slots <n>] [--weight <n>] --until <when>
|
|
|
38
44
|
conduct why <event id | item> [--json]
|
|
39
45
|
the rule behind an action, and the events that led to it
|
|
40
46
|
conduct console [--here]
|
|
41
|
-
open the console in a Windows Terminal
|
|
47
|
+
open the console in a tab (Windows Terminal, iTerm or Terminal, tmux), or in this terminal (see Console)
|
|
42
48
|
conduct escalations [--json] what rules escalated to the console and it hasn't handled
|
|
43
49
|
conduct escalations done <id> [--ask] <text>
|
|
44
50
|
the console's finding on an escalation; --ask puts it in the inbox
|
|
@@ -63,21 +69,31 @@ conduct mode --tier S|M|L --files <a,b>
|
|
|
63
69
|
conduct wait <condition> wait for an outside event (see Waits)
|
|
64
70
|
conduct state [--repo <repo>] the worktree's branch, changes and PR, and its other repos' (see Worker verbs)
|
|
65
71
|
conduct branch <type> <slug> [--item <item>]
|
|
66
|
-
rename worktree-
|
|
72
|
+
rename worktree-ENG-n to <type>/eng-n-<slug>
|
|
67
73
|
conduct checkout <owner>/<repo> <type> <slug> [--item <item>]
|
|
68
74
|
clone another GitHub repo into .scratch/repos/<repo> for the item
|
|
69
75
|
conduct pr create --title <title> --body-file <file> [--repo <repo>] [--item <item>] [--dry-run]
|
|
70
76
|
check, push and open the worker's PR (see Worker verbs)
|
|
77
|
+
conduct pr status <pr> [--repo <repo>]
|
|
78
|
+
any PR's verdict and merge commit, with no checkout (a read)
|
|
71
79
|
conduct pr threads <pr> [--repo <repo>]
|
|
72
80
|
the PR's threads, with the ones that wait on the worker
|
|
73
|
-
conduct
|
|
81
|
+
conduct pr abandon <pr> --reason <why> [--repo <repo>] [--force]
|
|
82
|
+
close the worker's PR unmerged, with a note saying why
|
|
83
|
+
conduct land <pr> --reason <why> [--mode auto|review] [--rearm] [--requeue <run>] [--tier S|M|L] [--repo <repo>]
|
|
74
84
|
arm the worker's PR to land and give its first verdict (see Worker verbs)
|
|
75
85
|
conduct reply <pr> [<thread>] --body-file <file> [--repo <repo>]
|
|
76
86
|
answer on the worker's PR, headed as a note
|
|
77
87
|
conduct runs <sha> [--for <pipeline>[,<pipeline>…]] [--repo <repo>]
|
|
78
88
|
a merge's runs, their verdict, failed logs and npm stages (see Worker verbs)
|
|
89
|
+
conduct runs <sha> --for <pipeline>[,<pipeline>…] --grep <pattern> [--repo <repo>]
|
|
90
|
+
the same, with the lines of those runs' logs that match, whatever their result
|
|
79
91
|
conduct runs <sha> --requeue <pipeline>
|
|
80
92
|
queue the item's merge's cancelled run again on the base (see Worker verbs)
|
|
93
|
+
conduct timeline <run> [--task <regex>] [--grep <regex>] [--repo <repo>]
|
|
94
|
+
a run's tasks with their times, and the named tasks' log lines that match (see Worker verbs)
|
|
95
|
+
conduct timeline --for <pipeline> [--branch <branch>] [--count <n>] [--task <regex>] [--grep <regex>] [--repo <repo>]
|
|
96
|
+
the same for a pipeline's last finished runs on a branch
|
|
81
97
|
conduct train [<project>] [--dry-run]
|
|
82
98
|
merge the waiting skill publishes into one registry PR (see Skill trains)
|
|
83
99
|
conduct ports [<path>] [--json] a conducted worktree's dev ports from the port broker, or every worktree's (see Broker)
|
|
@@ -85,13 +101,13 @@ conduct lock acquire|release|status <name> [--owner <id>] [--wait <minutes>]
|
|
|
85
101
|
a lock every project shares, such as chrome (see Broker)
|
|
86
102
|
conduct events [--since <id>] [--follow] [--console]
|
|
87
103
|
the event log, one JSON event per line (see API)
|
|
88
|
-
conduct install-startup start the supervised daemon at logon (
|
|
89
|
-
conduct install-startup --remove remove that
|
|
104
|
+
conduct install-startup start the supervised daemon at every logon (Task Scheduler, launchd or systemd)
|
|
105
|
+
conduct install-startup --remove remove that task, agent or unit
|
|
90
106
|
conduct upgrade [--tag dev|latest] [--allow-downgrade]
|
|
91
107
|
stop the daemon, npm install the tag's newest version, start it again
|
|
92
108
|
```
|
|
93
109
|
|
|
94
|
-
A `<worker>` is its item (`
|
|
110
|
+
A `<worker>` is its item (`ENG-309`) or its name (`probe-ENG-309`).
|
|
95
111
|
|
|
96
112
|
`conduct status --json` exits 1 with `running: false` when no daemon listens on its port, such as while it restarts or upgrades. It still lists the workers in flight, read from the store with `workersFrom: "store"` and activity `daemon not running`; a store it can't read leaves `workers` out. A running daemon's answer says `workersFrom: "daemon"`.
|
|
97
113
|
|
|
@@ -101,38 +117,51 @@ The session manager owns the `claude --bg` sessions it launches, and only those:
|
|
|
101
117
|
|
|
102
118
|
- **launch:** the worktree comes first, made by the daemon itself (design note v2, section 2; ATK-526), with no hooks:
|
|
103
119
|
1. the broker gives it a port for each name in the profile's `ports` (see Broker);
|
|
104
|
-
2. `git worktree add
|
|
120
|
+
2. `git worktree add`, run in the project's clone (see Projects), makes `~/.conductor/projects/<project>/worktrees/<name>` on `worktree-<name>` from `origin/<base>`, fetched first (after `git worktree prune`, and on Windows with `core.longpaths` on). It doesn't fetch, pull, add worktrees to or change a checkout of yours (a `localFilesFrom` checkout is only read). A worktree git still reads there is reused, its own changes and all; one with a `.git` git can't read is never deleted, and fails the launch. A `worktree-<name>` branch that is left is checked out again, with any commits it kept;
|
|
105
121
|
3. with `localFilesFrom` (an absolute path to a checkout the operator keeps), the gitignored files there that match its `.worktreeinclude` are copied in, as they are now;
|
|
106
|
-
4. the profile's `setup` commands run in order in the worktree, through the shell, with `CONDUCTOR_PORT_<NAME>` (such as `CONDUCTOR_PORT_WEB=41000`), `CONDUCTOR_ITEM`, `CONDUCTOR_PROJECT` and `CONDUCTOR_WORKTREE` set. They build its gitignored files from what the repo commits (
|
|
122
|
+
4. the profile's `setup` commands run in order in the worktree, through the shell, with `CONDUCTOR_PORT_<NAME>` (such as `CONDUCTOR_PORT_WEB=41000`), `CONDUCTOR_ITEM`, `CONDUCTOR_PROJECT` and `CONDUCTOR_WORKTREE` set. They build its gitignored files from what the repo commits (such as `pnpm install` and a script that writes the dev ports). The first that fails fails the launch, with the end of its output. So does a setup that leaves changes git sees, such as a committed lockfile that an install step rewrote: the worker would take them for its own;
|
|
107
123
|
5. the ports go into the worktree's `.claude/settings.local.json` as `env`, merged into what the file had. A `claude --bg` session doesn't inherit the environment it is started from, but Claude Code applies that file's `env` to every session in the folder and to each resume;
|
|
108
124
|
6. the profile's work and verify skills must be there: a `conductor:<skill>` in the plugin, any other in the worktree's `.claude/skills` or `~/.claude/skills`.
|
|
109
125
|
|
|
110
126
|
A launch that fails removes the worktree it made when it is clean, with its branch while that still has the launch's name; a worktree that was there before the launch stays.
|
|
111
127
|
|
|
112
|
-
Then `claude --bg -n <name> --plugin-dir <home>/plugin --disallowedTools EnterPlanMode ExitPlanMode --permission-mode auto -- <prompt>` runs with its cwd in the worktree (see Plugin), never `--worktree`. A
|
|
128
|
+
Then `claude --bg -n <name> --plugin-dir <home>/plugin --disallowedTools EnterPlanMode ExitPlanMode --permission-mode auto -- <prompt>` runs with its cwd in the worktree (see Plugin), never `--worktree`. Workers run headless. A machine whose `config.json` sets `"workers": { "tab": true }` also gets a tab attached to each session, `claude attach <session>`, in the terminal `workers.terminal` names (see Tabs); `conduct attach` opens one either way. A tab that fails to open is a `worker.tab-failed` event and never fails the launch.
|
|
113
129
|
- **park:** a worker with a wait (`POST /waits`) is stopped once `claude agents` shows its turn has ended. Its status becomes `parked`, and its tab closes.
|
|
114
130
|
- **resume:** when a wait resolves, the daemon lets the stop settle (`sessions.settleSeconds`). It checks the process has exited, then runs `claude --bg --resume <session> -- "[conductor] event: …"`. The session keeps its name, model, permission mode and disallowed tools. A resume of a session that still runs starts a copy; the daemon stops and removes the copy, then tries again.
|
|
115
131
|
- **compact:** a parked worker with no resolved wait, whose context the rules find too big (the default `compact-on-park`: past `sessions.compactTokens`, 300k), is resumed with `/compact <what to keep>` once its stop has settled (`worker.compacting`). It runs until its transcript shows the compaction's boundary (`worker.compacted`, with the context before it and the conversation it kept), then parks again on its waits, which resume it as usual. Each turn of a session re-reads its whole context from cache, so a worker left to grow pays for it every turn (ATK-454 reached 883k). A resume that starts a copy is tried again on a later sweep (`worker.compact-deferred`), as a resume is. A compaction whose resume fails, or whose turn ends with no boundary 10 minutes on, logs `worker.compact-failed`, which raises a `context` item in the inbox. The worker still parks, and the daemon doesn't try it again until it restarts or the session compacts some other way: any boundary in its transcript, Claude Code's own included, logs `worker.compacted` and resolves that item. A worker whose context is unknown is never compacted, so even a rule with no threshold can't compact it on every park.
|
|
116
|
-
- **retire:** refuses a worker whose turn still runs, unless `--force`, and never retires a session that is still starting. What happens to the worktree depends on the worker's PR (design note v2, section 2):
|
|
132
|
+
- **retire:** refuses a worker whose turn still runs or that is attached (open in a tab), unless `--force`, and never retires a session that is still starting. A refusal for an attach is logged once per attach, however often the scheduler tries again, and never raised in the inbox; the retire goes through once the attach is released (ATK-519). What happens to the worktree depends on the worker's PR (design note v2, section 2):
|
|
117
133
|
- **merged** (read from the code host): it refuses uncommitted or unpushed work unless `--force`, then stops the session, removes the worktree and deletes its local branch, and runs `claude rm`;
|
|
118
134
|
- **no PR, and nothing uncommitted or unpushed** (a canceled item, a worker retired before its first commit): as merged, with nothing to lose;
|
|
119
135
|
- **not merged** (abandoned or closed, or no PR with work in it): it stops and removes the session and frees the ports, and keeps the worktree and its branch, so nothing the PR didn't land is lost. `worker.retired` names it under `kept`, which raises one `failed` inbox item with the path, the branch and the commands that remove them. A PR the project's code host can't read at all counts as not merged;
|
|
120
136
|
- **unread** (the code host didn't answer): it refuses, as a refusal that passes on its own, and the scheduler tries again.
|
|
121
137
|
|
|
122
|
-
A probe's worktree and a `--force` retire's always go. The removal moves the worktree aside, has git forget it, deletes the branch and then the directory with Node, which unlinks pnpm's junctions without following them (`git worktree remove` leaves them on Windows). The delete is asynchronous, so the API goes on answering through a minute of `node_modules`. With the directory gone already, it deletes the `worktree-<name>` branch only when none of its commits is unpushed. If the stop fails, nothing is removed.
|
|
138
|
+
A probe's worktree and a `--force` retire's always go. The removal moves the worktree aside, has git forget it, deletes the branch and then the directory with Node, which unlinks pnpm's junctions without following them (`git worktree remove` leaves them on Windows). The delete is asynchronous, so the API goes on answering through a minute of `node_modules`. It works in the repository the worktree's own `.git` names, so a worker launched before the daemon owned clones retires from the checkout its worktree was made in; it removes only a linked worktree that repository lists, never a main checkout. With the directory gone already, it deletes the `worktree-<name>` branch in the project's clone only when none of its commits is unpushed. If the stop fails, nothing is removed. If the stop succeeds (or a fresh listing shows no process to stop) and a later step fails, `worker.retire-partial` says `parked`: the worker is parked with its waits closed: no sweep reads it as stopped unexpectedly, resumes it or compacts it, and the retire is tried again.
|
|
123
139
|
- **restarts:** a launch the daemon was stopped in adopts its session if one was started, and otherwise fails and removes its clean worktree.
|
|
124
|
-
- **attach:** a parked worker is resumed with no event text, so it wakes idle and keeps running when its tab closes. An attached worker isn't parked until it registers a new wait, or until its turn has been over `sessions.idleMinutes` (`worker.detached`).
|
|
140
|
+
- **attach:** a parked worker is resumed with no event text, so it wakes idle and keeps running when its tab closes. The daemon sees the tab by its `claude attach <session>` client process (Windows PowerShell's `Win32_Process` on Windows, `ps` elsewhere), on each attach and, while a worker is attached, at most every 30 s of the sweep (ATK-520). An attach of a running worker that has a client already (its attach's tab, its launch's, or one opened by hand in another terminal) opens no second tab: it prints `already attached to <name> (its tab is open)`, logs `worker.attached` with `tabOpen: true`, and marks the worker attached if it wasn't. An attached worker isn't parked until it registers a new wait, until its turn is over and two scans in a row found no client after one since the attach had found it (`worker.detached`, `reason: tab closed`), or until its turn has been over `sessions.idleMinutes` (`worker.detached`). A client counts by the worker's short id, session id or name, or any id it had since the attach. A tab whose client no scan found (a tab that failed to open, a worker woken outside the daemon, a `ps` that shows only the client's process title) is never taken as closed, so only the idle release ends that attach, which also delivers a wait that resolved while the worker was attached. The scan is proven on Windows; on macOS and Linux it depends on `ps` showing the client's arguments. A wait that resolves after the attach, on a worker whose transcript shows no turn since then, is delivered at once: the worker is detached (`reason: wait resolved`), parked, which closes its tab, and resumed with the event. A worker someone woke outside the daemon counts as attached since its stop, so the turn that woke it counts. One that took a turn since the attach (or has no transcript to tell), or whose wait resolved before the attach, keeps it until the idle release, logs `worker.resume-held` (`reason: attached`) once, and `conduct status` shows it as `idle, holding resolved waits for the attached session`.
|
|
125
141
|
- **park failures:** a session whose process outlives `claude stop` by 30 s logs one `worker.park-failed` per spell, which raises a `stall` in the inbox (see Inbox), and the park is tried again every 5 minutes.
|
|
126
|
-
- **stalls:** a permission prompt open `sessions.permissionMinutes` (5), or idle `sessions.idleMinutes` (20) with no wait, logs one `worker.stalled` event per spell, and `worker.stall-cleared` when that spell ends. `claude agents` names no command, so a permission stall reads the session's transcripts (its own and its subagents') for the call still open: its tool, the command or path, the subagent that made it, and the auto-mode classifier denials just before it. The inbox item and its push say them, for example "
|
|
142
|
+
- **stalls:** a permission prompt open `sessions.permissionMinutes` (5), or idle `sessions.idleMinutes` (20) with no wait, logs one `worker.stalled` event per spell, and `worker.stall-cleared` when that spell ends. `claude agents` names no command, so a permission stall reads the session's transcripts (its own and its subagents') for the call still open: its tool, the command or path, the subagent that made it, and the auto-mode classifier denials just before it. The inbox item and its push say them, for example "ENG-312 has waited 5 min on a permission prompt: Bash `git -C … diff …` in subagent general-purpose (Review ENG-312 diff vs plan), after 2 auto-mode denials". Auto mode asks a person after 3 classifier denials in a row (20 in a session), even in a background subagent, so that count is usually why the prompt came. An open item of a Handoff kind with nothing to watch (`abandoned`, `blocked`, `failed`, `human`, `operator`, `scope`) on the worker, or on its item with no worker, counts as a wait: its idle clock starts once that item resolves. A `merge`, `npm-otp` or `plan` item doesn't: those come with a wait, so a worker idle with only the item still stalls.
|
|
127
143
|
- **spend:** tokens from the session's transcript and its subagents' (`~/.claude/projects/*/<session>.jsonl`), each message counted once, go into `spend` per worker and local day. It keeps input, output, cache read and cache write; `tokens` is their sum. A worker whose tokens in all, not counting cache reads, pass `inbox.runawayTokens` logs `worker.runaway` once per daemon run. Cache reads are left out because they are nearly all of a session's tokens (97% on ATK-312) at a tenth of the price. The spend `conduct status`, `GET /fleet` and the console's fleet pane show (`spend.today`, `spend.total`) and the `spend.*` rule fields count the same tokens: input, output and cache writes. Status shows the worker's cache reads beside them, labelled (`spend 417k today, 1.0M total, 25M cache reads`; `spend.cacheRead` in `GET /fleet`).
|
|
128
144
|
- **context:** the session's context is its main transcript's newest model call: input, cache read, cache write and output. Subagents have contexts of their own, so theirs don't count. After a compaction it is unknown until the next call: the boundary counts only the conversation it kept, and the call adds the system prompt and tools back (a probe that kept 2.8k next read 68k). Status shows it as `context 312k` (`context` in `GET /fleet`), and the rules read it as `worker.context`.
|
|
129
145
|
- **probes:** `--probe` names the worktree and session `probe-<item>` and gives it a prompt that works no issue, so tests stay out of the live fleet.
|
|
130
146
|
|
|
131
147
|
Worker statuses: `launching`, `running`, `parked`, `stopped-unexpectedly`, `failed`, `exited`. Every step is an event (`worker.launched`, `worker.parked`, `worker.resumed`, …) on `GET /events`.
|
|
132
148
|
|
|
149
|
+
### Tabs
|
|
150
|
+
|
|
151
|
+
Workers are background sessions, and a tab is only a view of one: the session runs without it, and a tab closes when its worker parks. `~/.conductor/config.json` says whether each launch opens one and where tabs open:
|
|
152
|
+
|
|
153
|
+
```jsonc
|
|
154
|
+
{ "workers": { "tab": true, "terminal": "auto" } }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `tab` (false): open a tab on each launch. `conduct attach <worker>` opens one whatever it says. It was the profile's `workers.tab` until ATK-529: a profile may still set it, for older daemons, but it is ignored, and `conduct project check` warns about it. Set it in `config.json` on each machine that wants tabs.
|
|
158
|
+
- `terminal` (`auto`): `windows-terminal` (`wt.exe`, a window per project, "Conductor · <project>"), `iterm` (a tab in its front window), `terminal` (macOS Terminal, a window per tab) or `tmux` (a detached session per window, such as `conductor-web`, and a tmux window per tab). `auto` is Windows Terminal on Windows, iTerm on macOS where it is installed and Terminal where not, and tmux elsewhere.
|
|
159
|
+
|
|
160
|
+
A tmux tab runs where no desktop is, so `conduct attach` and `conduct console` print the `tmux attach -t <session>` that reaches it. With no terminal to open in (no tmux on a server), `conduct attach` prints the `claude attach <session>` to run yourself, and `conduct console` refuses with `conduct console --here --force` (`--force` because the refused start is claimed for a minute). A worker's tab whose `claude attach` fails stays open on the failure, until Enter, so you can read it. Tabs on macOS and tmux run their command through `sh -c`, whatever the login shell. macOS asks once whether the conductor may control Terminal or iTerm (Automation, in Privacy & Security). Terminal opens a window per tab, and closes it when the tab's shell exits only if the profile's "When the shell exits" says so (Settings, Profiles, Shell); otherwise a parked worker leaves its window behind.
|
|
161
|
+
|
|
133
162
|
## Notes
|
|
134
163
|
|
|
135
|
-
A worker writes each phase note (`## <work skill> · <Phase>[ · <kind>: <one line>]`, such as `## work-
|
|
164
|
+
A worker writes each phase note (`## <work skill> · <Phase>[ · <kind>: <one line>]`, such as `## work-item · PR opened`) with `conduct note <item> <file>`. The daemon posts the file as a comment on the item in the tracker, then records the phase at once: on the worker (`phase` in `GET /fleet` and `conduct status`), on the item, and as a `note.posted` event. A PR opened note also records the worker's PR (`pr`, such as `ado:Contoso.Web#1850` or `github:<owner>/<repo>#458`) from its first PR link, replacing an earlier one; until then, the worker's first `pr:` wait records it. `conduct status` and the console's fleet pane show it. The worker is `--worker <worker>`, else the one whose worktree is the current checkout, else the project's live worker on that item. The project is the worker's, else the one whose tracker key (the Linear team, or the Boards or GitHub Issues `key`) is the item's key prefix.
|
|
136
165
|
|
|
137
166
|
**State follows the notes** (providers design note, section 5). A `Started`, `PR opened` or `Landed` note (not a Handoff) also moves the item, to the state its phase maps to in the profile's `tracker.transitions`:
|
|
138
167
|
|
|
@@ -146,7 +175,7 @@ A phase mapped to `null` moves nothing (`started` still assigns). A `Started` ne
|
|
|
146
175
|
|
|
147
176
|
**Worker protocol 2.** The notes, their phases and kinds, the waits and the wake text are the package's spec, [`protocol/PROTOCOL.md`](protocol/PROTOCOL.md), held as data in `src/protocol.ts`. `conduct note` checks each note against it before anything is posted, on the daemon and with no daemon alike. It refuses one with an unknown phase, a Handoff without a kind and its line, a kind its phase doesn't carry (`jason` is `operator` since protocol 2), or a Plan without its `**Size tier**` and `**Landing:**` lines, and the error names the rule. The readers (watchers, briefs, history) still read older notes and people's comments.
|
|
148
177
|
|
|
149
|
-
The work skill is the profile's `skills.work`. A note headed with one of `skills.aliases` counts the same, there and wherever the daemon reads notes (approvals, `conduct wait item … note`, the review-thread checks of `conduct wait pr … comment`). So when a project renames its work skill
|
|
178
|
+
The work skill is the profile's `skills.work`. A note headed with one of `skills.aliases` counts the same, there and wherever the daemon reads notes (approvals, `conduct wait item … note`, the review-thread checks of `conduct wait pr … comment`). So when a project renames its work skill, the old name goes in `aliases` and items in flight keep their phases.
|
|
150
179
|
|
|
151
180
|
| Exit | Means | The worker then |
|
|
152
181
|
|---|---|---|
|
|
@@ -184,7 +213,7 @@ A brief is kept for 30 seconds. Anything the daemon sees happen to the item drop
|
|
|
184
213
|
|
|
185
214
|
The rest of what a work skill and the console read from the tracker, so neither needs a tracker's MCP tools (design note v2, section 4). Each works for Linear, ADO Boards and GitHub Issues.
|
|
186
215
|
|
|
187
|
-
- `conduct notes <item>` prints the item's phase notes with their whole text, oldest first, each with when and who. `--phase` and `--kind` keep the ones that match (case aside), and `--last` the newest of those: `conduct notes
|
|
216
|
+
- `conduct notes <item>` prints the item's phase notes with their whole text, oldest first, each with when and who. `--phase` and `--kind` keep the ones that match (case aside), and `--last` the newest of those: `conduct notes ENG-312 --phase Handoff --kind conductor --last` is the Handoff a console escalation is about.
|
|
188
217
|
- `conduct comments <item>` prints every comment the same way, and whether an approver wrote it; `--since <time>` keeps the newer ones.
|
|
189
218
|
|
|
190
219
|
Both read the item's brief (the comments now carry their author and their note heading), so they read as the daemon reads, else here, and refuse as `conduct brief` does. `--json` prints them as one object.
|
|
@@ -198,18 +227,30 @@ Protocol 2's verbs on git and the code host (providers design note, section 4),
|
|
|
198
227
|
- `conduct state`, `conduct branch` and `conduct checkout` are git in the worktree, so they always run here. `state` reads the branch's PR as the project's account (see Accounts). `checkout` clones another GitHub repo into `.scratch/repos/<repo>`, keeps it out of the worktree's git status, and retire checks it for unpushed or uncommitted work (ATK-429).
|
|
199
228
|
- `conduct pr create` checks everything before it pushes, `--dry-run` included: the title carries the item key, the branch carries it too and isn't the base, the description fits the host (ADO 4,000 characters, GitHub 65,536), no PR is open from the branch yet, nothing is uncommitted, the branch is ahead of its base, and commitlint passes (skipped in a clone without it installed, whose own CI lints the PR). Then it pushes from the checkout and has the daemon open the PR (`POST /workers/:id/prs`). The daemon checks ownership again from its own records and the worktree, never from the request: the worker is live, the branch the worktree is on now (or the `--repo` checkout's) carries the worker's item, and the title carries it. It records the PR on the worker (`pr`; another repo's only while the worker has none) and logs `code-host.pr-created`. `conduct branch` renames only the item's own branch, never the base, `main` or another item's.
|
|
200
229
|
- `conduct land <pr>` sets the worker's PR up to land, as the helper's `land` did, and is safe to repeat. The PR must be the worker's (the same checks, and from the branch the checkout is on now, into its base). A brake already on it (a comment, a failed build, conflicts) is reported, and nothing is armed. A Landing note whose landing armed something, with that gone now, means someone cancelled it: `auto-merge-cancelled`, until `--rearm`. A PR ADO started completing in the last few minutes (`completionQueueTime`), which clears auto-complete, is `waiting` instead, and nothing is armed. `--mode` is held to the profile's landing rules (`work.landing`) for the PR's files as pushed (`origin/<base>...origin/<head>`, fetched first) and the plan's tier. The daemon reads the tier from the item's Plan note; in-process it takes `--tier`, and auto is refused without one. Without `--mode` the rules pick it. Then it arms the host's own landing behind its capability check (`landingCheck`). On ADO that is a +10 vote in auto mode, with auto-complete and noFastForward. On GitHub it is auto-merge with a merge commit in auto mode, where the repo allows it; in review mode nothing is armed. It posts the Landing note last, and a new one whenever the mode changes, then prints the verdict. A failed build comes with its log, and a merge with whether it has two parents. The daemon logs `code-host.landing-armed` with the mode.
|
|
230
|
+
- **`--requeue <run>`.** On a `build-failed` PR it queues that failed build again, for a flake no change caused, then arms as above, so it isn't safe to repeat. On ADO that is `PATCH policy/evaluations/<id>`, as keeping a landing armed does for an expired build. On GitHub (ATK-640) it is `POST actions/runs/<run>/rerun-failed-jobs` on the check's Actions run, for a PR the item opened from its `.scratch/repos` checkout. A PR a hook's publish opened has no checkout, so `land` refuses it. It checks the landing (`landingCheck`, and a cancelled landing is reported with nothing queued) and then posts a closed Requeue note, `## <skill> · Requeue · run: <run> (<build>) at <head>`, with `--reason` as the why. The note is the record, so it holds with or without the daemon: a build with a Requeue note on the PR's head commit is refused another requeue there, even for the new run's failure, which is fixed and pushed instead. Each build that failed on the head gets one, and a push gives the new head its own. The note is read from the comment that opened its thread, so a reply in it changes nothing. When queueing fails after the note, that build's requeue on the head is spent and the error says so. Until the PR shows the new run, the verdict (`conduct land`, `conduct wait pr`) waits on the build whose run a note names instead of reporting the old failure; after 15 minutes with the old run still there, it is `build-failed` again. A GitHub re-run keeps the run's id, so a failed check of that run that finished after the note (`finished`, the check's `completed_at`) is the new attempt's failure, `build-failed` at once. It refuses a PR that isn't `build-failed`, a run that isn't one of its failed builds, an ADO build with no policy evaluation, a GitHub run with a job still running (GitHub re-runs only a finished run), and a PR with a comment waiting for an answer (on GitHub the Requeue note would close it). The daemon logs `code-host.build-requeued` with the run and head.
|
|
201
231
|
- `conduct wait pr <pr>` (no event word) is the verdict wait, `pr:<host>:<repo>#<n>:verdict`: it holds on the first verdict that isn't `waiting`, with a line such as `pr 2030 build-failed: <pipeline>`. While it waits on a PR whose landing the daemon recorded, the ADO watcher keeps it armed. It queues an expired build again (once per wait) and, in auto mode, restores a cleared vote (never over a negative one), logged as `code-host.kept-armed`. A local wait (no daemon) gives the same verdicts but keeps nothing armed.
|
|
202
232
|
- `conduct pr create` ends the description with the tracker's reference when it has one, before the length check: `AB#<id>` for an ADO Boards item on a GitHub PR, so the Boards GitHub app links it, and `Refs <owner>/<repo>#<n>` for a GitHub Issues item on a GitHub PR. Linear has none.
|
|
203
|
-
- `conduct file --title … --body-file … --area … [--related …] [--blocked-by …] [--human] [--waits-release]` files a follow-up of the checkout's item: in Backlog, in the item's project, with one Area (a label of the profile's Area group, a path below a board's `areaRoot`, or the name after a GitHub Issues `areaPrefix` label), the profile's `triageLabel`, its `humanLabel` with `--human`, and its `releaseWait` label (a tag on Boards) with `--waits-release` (the follow-up then waits until each Done blocker has shipped). It is related to the item and each `--related`, and waits on each `--blocked-by` (keys, repeated or comma-separated). A key of another project, an Area outside the group, or `--waits-release` without a `--blocked-by` or on a profile with no `tracker.releaseWait`, is refused before anything is written. On Linear a relation that fails after the issue exists is a warning; on Boards the item and its links are one request. On GitHub Issues the follow-up also carries the `queueLabel`, the related items are a `Related: #n` line in its body (GitHub has no related relation), and a dependency that fails after the issue exists is a warning. With `--waits-release`, Linear and GitHub Issues look up each `--blocked-by` first and refuse one they don't have. The daemon files it for the worker (`POST /workers/:id/follow-ups`) and logs `tracker.filed`. The worker searches for a duplicate first, with `conduct search`: this only files.
|
|
233
|
+
- `conduct file --title … --body-file … --area … [--related …] [--blocked-by …] [--human] [--waits-release]` files a follow-up of the checkout's item: in Backlog, in the item's project, with one Area (a label of the profile's Area group, a path below a board's `areaRoot`, or the name after a GitHub Issues `areaPrefix` label; none, and no `--area`, for a Linear team with no Area group), the profile's `triageLabel`, its `humanLabel` with `--human`, and its `releaseWait` label (a tag on Boards) with `--waits-release` (the follow-up then waits until each Done blocker has shipped). It is related to the item and each `--related`, and waits on each `--blocked-by` (keys, repeated or comma-separated). A key of another project, an Area outside the group, or `--waits-release` without a `--blocked-by` or on a profile with no `tracker.releaseWait`, is refused before anything is written. On Linear a relation that fails after the issue exists is a warning; on Boards the item and its links are one request. On GitHub Issues the follow-up also carries the `queueLabel`, the related items are a `Related: #n` line in its body (GitHub has no related relation), and a dependency that fails after the issue exists is a warning. With `--waits-release`, Linear and GitHub Issues look up each `--blocked-by` first and refuse one they don't have. The daemon files it for the worker (`POST /workers/:id/follow-ups`) and logs `tracker.filed`. The worker searches for a duplicate first, with `conduct search`: this only files.
|
|
204
234
|
- `conduct mode --tier S|M|L --files <a,b>` gives the landing mode a plan states before its PR exists, with the reasons, by the rule `conduct land` holds the PR to (`work.landing`). It reads only the profile, so it runs here.
|
|
235
|
+
- `conduct pr status <pr>` gives any PR's verdict, read as `land` reads the worker's own, with no checkout, no ownership check and nothing armed: a PR the item didn't open, such as one a skill publish opened in the registry. On `merged` it names the `mergeCommit` and `noFastForward` (whether that commit has two parents), so a worker passes it to `conduct runs` (ATK-534). A `conduct wait pr` that ends on a merge names the merge commit too: `pr <repo>#<n> merged as <sha>`.
|
|
205
236
|
- `conduct pr threads <pr>` lists the PR's threads (on GitHub each comment and review) with `needsAnswer` (ATK-384). `conduct reply <pr> [<thread>]` answers on the worker's own PR: in the ADO thread named, or as a note on the PR. The text is headed `## <work skill> · Reply` unless it starts with a note heading. The daemon logs `code-host.replied`. `--repo` takes `github:<owner>/<repo>` (from its `.scratch/repos` checkout, into its default branch) or `ado:<repository>`; the profile's `codeHost` is the default.
|
|
237
|
+
- `conduct pr abandon <pr> --reason <why>` closes the worker's PR unmerged, such as one another item's PR made redundant (ATK-479): ADO sets its status to `abandoned` (`PATCH pullrequests/<n>`), GitHub closes it (`PATCH pulls/<n>`). Then it posts a closed `## <work skill> · Abandoned` note with the reason; a note that fails after the PR closed comes back as `noteError`, not an error. The PR must be the item's: its title and head branch carry the key, and it isn't from its base. It must also be from the branch the checkout is on now, unless `--force`, which takes the item's PR from another of its branches (an earlier one, a follow-up's), never another item's. A merged PR is refused. A closed one stays closed (`already: true`) and gets the note only when it has no Abandoned note yet, so it is safe to repeat, and a second run posts a note that failed the first time. The daemon abandons it for the worker (`POST /workers/:id/abandons`) and logs `code-host.pr-abandoned`.
|
|
206
238
|
- `conduct runs <sha>` is one look at a merge's runs, as the helper's `runs-watch --once` and `stage` were (ATK-455). It is a read, so it runs here (except with `--requeue`).
|
|
207
|
-
- **Which runs.** On Azure Pipelines (the profile's `ci: ado-pipelines
|
|
208
|
-
- **
|
|
209
|
-
- **
|
|
210
|
-
-
|
|
239
|
+
- **Which runs.** On Azure Pipelines (the profile's `ci: ado-pipelines`), they are the pipelines whose YAML trigger the merge fires. The YAML and the merge's changed files are read in the worktree, which fetches the base when it hasn't got the commit. On GitHub Actions (`--repo github:<owner>/<repo>`, or `ci: github-actions` for a GitHub repo), they are the runs the merge's push started.
|
|
240
|
+
- **CI apart from the code host (ATK-530).** Azure Pipelines can build the project's GitHub repo: `"ci": { "kind": "ado-pipelines", "organization": "https://dev.azure.com/<org>", "project": "<project>" }` names where its pipelines live (beside an Azure DevOps code host, both are refused: its pipelines are the repo's own project's). They are the definitions there whose repository is the GitHub repo, named `<owner>/<repo>` and of type `GitHub`. `code-host.ts`'s `ciOf` picks a repo's CI: the profile's for its own repo, its own host's for any other. The runs, `wait merge` (on the ADO source, as the project's ADO account, with the facts from the worktree's git), `wait run`, the grace period (by the CI: 15 minutes for Azure Pipelines) and `project check` (which also asks the ADO credential) follow it. Its PRs stay GitHub's. `--requeue` queues the code host's own CI, so it refuses a GitHub repo Azure Pipelines builds: queue the run in Azure Pipelines, and `conduct wait merge` follows it. A profile without the organization and project still reads, as it did before they existed, and its CI is refused where it is read.
|
|
241
|
+
- **The verdict.** It is `runsVerdict`: `succeeded`, `failed`, `cancelled`, `waiting-for-approval`, `missing`, `none` or `waiting`. On Azure Pipelines `none` comes at once when nothing is triggered. On GitHub it comes after 5 minutes with no run, and on Azure Pipelines a triggered pipeline gets 15 minutes to queue before it is `missing`. The note gives a `missing` pipeline's `--requeue` line.
|
|
242
|
+
- **A batch behind a run (ATK-434).** A `batch: true` pipeline holds a push while its run on the base still goes, and builds the newest commit when that run ends. So a merge with no run of such a pipeline yet reads that pipeline's newest builds on the base, and the newest that doesn't carry the merge is the run its batch queues after (`behind`: its `run`, and `finished`, null while it goes; state `not-queued`). The verdict is `waiting` for as long as that run goes and 2 minutes after it ends, while Azure DevOps lists the batch's run, past the 15 minutes too: a long deploy, or a `main` run waiting on its prod approval, can hold it longer. A `[skip ci]` release commit pushed in that time isn't in the deploy pipelines' batch (it touches only the released package's files), and the batch still runs.
|
|
243
|
+
- **A cancelled run (ATK-547).** On Azure Pipelines a canceled build is `cancelled`, not `failed`. A later run on the base whose commit contains the merge stands in for it: the first that wasn't cancelled is the merge's run, with `supersedes` naming the cancelled one. So `cancelled` means no newer run carries the merge, and the note gives the `--requeue` line. GitHub reads runs by the merge's SHA alone, so its `cancelled` says nothing about newer runs: its note says a newer run may carry the merge, and gives the line to re-run it while none does.
|
|
244
|
+
- **A failed run (ATK-638).** A failed run stands until a run queued by hand after it (`reason: manual`: `--requeue`, or a person's Run pipeline) contains the merge: that one is then the merge's run, with `supersedes` naming the failed one. A newer push's run never stands in for a failed one, nor for a run queued again for it and then cancelled, so another merge's pass doesn't hide a failure; each run queued again for a failure names it (`retryOf`). The note on `failed` gives the `--requeue` line, to run once when the log shows a flake that no change caused (another merge's real failure isn't one); it leaves it out for a run with `retryOf`, and gives the lines of any cancelled or missing pipelines beside it.
|
|
245
|
+
- **`--requeue <pipeline>`.** It queues that pipeline on the base at its tip (`POST build/builds` with the tip as `sourceVersion`), after checking that the tip contains the merge: a cancelled run no newer run carries, a failed one, or a missing one (`replaces: null`). It is a write, for the item's own merge only: the checkout's branch carries the item's key, and that branch's PR merged as `<sha>`. It refuses a run that is waiting, running or succeeded, so a run already listed is never queued twice, and a batch still behind a run, which queues when that run ends. A failed run is queued again once: it refuses a failed run that was itself queued again for a failed one (`retryOf`), so a real failure can't loop. The record is the merge's runs on Azure DevOps, so it holds with or without the daemon. A failed run needn't wait out the 15 minutes, since no push's run stands in for it. It also refuses a cancelled or missing run under 15 minutes after the merge, since Azure DevOps can still list a newer run that carries it. A newer merge's batched run that ADO hasn't listed yet isn't caught. It refuses a pipeline the merge didn't trigger. The daemon queues it for the worker that owns the checkout (`POST /workers/:id/requeues`) and logs `code-host.run-requeued`; with no daemon it runs here. `conduct wait merge <sha>` then follows the new run, which carries the merge.
|
|
246
|
+
- **`--requeue` on GitHub Actions (ATK-640).** For the project's GitHub repo, or one the item checked out under `.scratch/repos` (`--repo github:<owner>/<repo>`), it re-runs the merge's run itself. That is `POST actions/runs/<id>/rerun-failed-jobs` for a failed run and `…/rerun` for a cancelled one, so the run keeps its id. `runsForMerge` reads it by the merge's SHA, which gives its newest attempt (`attempt`, GitHub's `run_attempt` when above 1), so `conduct wait merge <sha> --repo …` follows it. The item's merge is checked as on Azure, from the checkout's branch and its PR. A run is re-run once: GitHub's `run_attempt` keeps the record, and an `attempt` above 1, failed or cancelled, is refused. A re-run builds the run's own commit, not the base's tip, so the adapter re-runs only the workflow's newest run on the base the PR merged into, of any event and whatever its status (a run someone started by hand counts). When a newer one exists, it refuses and names it: re-running an older merge's run after it could build or deploy older content. A missing run has nothing to re-run and is refused. Azure's 15-minute wait and tip check don't apply, since both come from queueing at the tip. The note on `failed` or `cancelled` gives the line with `--repo` for a run not yet re-run.
|
|
211
247
|
- **`--for`.** It settles on the named pipelines alone, by YAML file name, path or name, and lists the rest under `others`. Azure Pipelines refuses a name no pipeline answers to.
|
|
212
|
-
-
|
|
248
|
+
- **`--grep <pattern>` (ATK-622).** With `--for`, it reads every task log of those pipelines' runs, succeeded or not (`Ci.taskLogs`: Azure Pipelines' timeline tasks under their stage, through the reader `failureLog` uses; GitHub Actions' `gh run view --log` steps under their job). It tests each line against a JavaScript regular expression, case-sensitive. `grep.runs` gives each run's `matches` by stage and task, at most 200 lines a task with `truncated` beyond that, and `searched`, each task with its line count. So a dev proof can show that a run never printed a line. Some runs carry a note saying why they were searched in part or not at all: a pipeline with no run yet (`run: null`), a run still queued or running, one waiting on an approval (its later stages haven't run), and a log the provider won't give. A transient failure or a lost login is thrown instead.
|
|
249
|
+
- **Logs and stages.** Each failed run comes with its failed tasks and log tail (`logs`). For the project's repo, each of the profile's `releases` whose run succeeded gives what it staged (`stages.<key>`): package, version, `stageId`, the `npm stage approve` line, and `published`. Each release has a `kind`, which says how its stage is read and which gate a person answers it by (`src/release-kinds.ts`, ATK-530). `npm-stage` is the only kind: the run's `npm stage publish` line, whether npm has the version, and the `npm-otp` gate. The profile's `gates` take `plan`, `merge` and each kind's gate, and `conduct project check` warns about a release whose gate they leave out.
|
|
250
|
+
- `conduct timeline` (ATK-623) reads when a run's tasks ran, so a worker can measure a step (a cache that has to pay for itself) without a merge commit. It is a read, so it runs here.
|
|
251
|
+
- **One run.** `conduct timeline <run>` gives the run's start, finish and `seconds`, and each task in the order it started, with its `stage`, `job`, `started`, `finished`, `seconds` (null until it has both times), and the host's `state` and `result` (`Ci.timeline`: Azure Pipelines' timeline `Task` records under their stage and job; GitHub Actions' jobs API steps under their job, with no stage).
|
|
252
|
+
- **A pipeline's last runs.** `conduct timeline --for <pipeline>` reads the last finished runs of one pipeline on a branch instead, newest first (`Ci.recentRuns`): the profile's base and 3 runs by default, `--branch` and `--count` (at most 20) to change them. The pipeline is named as `runs --for` names one, and a name no pipeline, or more than one, answers to is refused. A branch with fewer runs says so in a note.
|
|
253
|
+
- **`--task <regex>` and `--grep <regex>`.** `--task` keeps the tasks whose name matches, not case-sensitive, so `Cache Next build` also keeps its `Post-job: Cache Next build` save. One that matches nothing says so and lists the run's tasks. `--grep`, which needs `--task`, reads only those tasks' logs (`Ci.taskLogs` with `only`) and gives `grep` in `runs --grep`'s shape: `matches` and `searched` by task, with a note for a log the provider won't give. A run not finished says its tasks may be partial.
|
|
213
254
|
- `conduct wait merge <sha> [--for …] [--repo …]` parks the worker until that verdict isn't `waiting` (see Waits), on either CI, so no worker polls with `wait time`. It absorbs ATK-430, ATK-246 and ATK-182.
|
|
214
255
|
|
|
215
256
|
## Writes
|
|
@@ -227,12 +268,14 @@ Every write the conductor makes outside its own store, with what triggers it and
|
|
|
227
268
|
| A pushed branch | `conduct pr create` (a worker), from its own checkout | git's own credential helper |
|
|
228
269
|
| A PR opened (ADO: noFastForward, delete the source branch on completion) | `conduct pr create` → `POST /workers/:id/prs` | the project's `az` identity, or its gh account |
|
|
229
270
|
| ADO: a +10 vote (auto mode) and auto-complete; GitHub: auto-merge with a merge commit (auto mode). Then the Landing note | `conduct land` → `POST /workers/:id/landings` | the project's `az` identity, or its gh account |
|
|
271
|
+
| ADO: a PR's failed build queued again, once per build and head commit, after its Requeue note | `conduct land --requeue` → `POST /workers/:id/landings` | the project's `az` identity |
|
|
230
272
|
| ADO: an expired build queued again, a cleared vote restored | a verdict wait on a landing `conduct land` armed (the ADO watcher, `code-host.kept-armed`) | the project's `az` identity |
|
|
231
273
|
| A reply on the worker's PR | `conduct reply` → `POST /workers/:id/replies` | the project's `az` identity, or its gh account |
|
|
232
|
-
|
|
|
274
|
+
| The worker's PR closed unmerged (ADO: abandoned), then its Abandoned note | `conduct pr abandon` → `POST /workers/:id/abandons` | the project's `az` identity, or its gh account |
|
|
275
|
+
| ADO: a pipeline queued on the base, for the worker's merge whose run was cancelled, failed or is missing | `conduct runs <sha> --requeue` → `POST /workers/:id/requeues` | the project's `az` identity |
|
|
233
276
|
| A train: its branch, the members' merges into it, its PR, a note on each member | `conduct train` (you) | the project's gh account |
|
|
234
277
|
|
|
235
|
-
Accounts are per project (see Accounts). Each code-host write is an event: `code-host.pr-created`, `code-host.landing-armed`, `code-host.kept-armed`, `code-host.replied`, `code-host.run-requeued`. So is each tracker write a worker causes: `tracker.transitioned` (or `tracker.transition-failed`) and `tracker.filed`. Rules still never change an item's state: only its phase notes do.
|
|
278
|
+
Accounts are per project (see Accounts). Each code-host write is an event: `code-host.pr-created`, `code-host.landing-armed`, `code-host.kept-armed`, `code-host.replied`, `code-host.pr-abandoned`, `code-host.run-requeued`, `code-host.build-requeued`. So is each tracker write a worker causes: `tracker.transitioned` (or `tracker.transition-failed`) and `tracker.filed`. Rules still never change an item's state: only its phase notes do.
|
|
236
279
|
|
|
237
280
|
## Inbox
|
|
238
281
|
|
|
@@ -244,11 +287,11 @@ The `inbox` table holds everything that waits on the operator, across projects.
|
|
|
244
287
|
- the exact commands, and its links, each named (`{ label, href }`);
|
|
245
288
|
- when it was raised.
|
|
246
289
|
|
|
247
|
-
The console gives each link a button of its own, `open <label>`, and `conduct inbox` and `/inbox` print it as `<label>: <href>`. The daemon names the links it knows: a run is `run <n>`, a conflict's PR is `PR <n>`, a refused file is `rules` or `profile`, a
|
|
290
|
+
The console gives each link a button of its own, `open <label>`, and `conduct inbox` and `/inbox` print it as `<label>: <href>`. The daemon names the links it knows: a run is `run <n>`, a conflict's PR is `PR <n>`, a refused file is `rules` or `profile`, a train's are `<repo> PRs` and `train PR <n>`. Every other link is named from its shape:
|
|
248
291
|
|
|
249
292
|
- A pull request is `PR <n>`, or `<repo> PR <n>` when it isn't the project's repo.
|
|
250
293
|
- An Azure Pipelines or GitHub Actions run is `run <n>`.
|
|
251
|
-
- A tracker item is its key: `
|
|
294
|
+
- A tracker item is its key: `ENG-312`, `CAD-1234` for a Boards work item, `PAR-12` for an issue of the project's GitHub Issues repo. A comment on an item or a PR adds ` note` (`ENG-312 note`, `PR 2078 note`). A work item or issue of another project is `work item <id>` or `<repo> #<n>`.
|
|
252
295
|
- Anything else is its host, or a path's last segment.
|
|
253
296
|
|
|
254
297
|
Labels longer than 40 characters are cut. Schema 9 named the links stored before this from their shape alone, with each item's project's profile, so a migrated checkout root reads as its folder's name rather than `checkout`.
|
|
@@ -259,7 +302,7 @@ An item resolves itself when its condition is seen, and records what resolved it
|
|
|
259
302
|
|---|---|---|
|
|
260
303
|
| `plan` | a `Plan` note (`conduct note`), or an `approval` wait; not a Plan an `auto-approve now` rule approves (unless its comment fails). An answer by someone who isn't an approver adds `(ignored approve from <name>: not an approver)` to its text | the approval wait resolving (an approver's `approve` or `revise:` comment), a `Plan approved` note, a newer Plan, or `conduct approve` / `revise` |
|
|
261
304
|
| `merge` | a `Handoff · merge: <PR>` note; a `pr … merged` wait attaches to it. Held while its PR's checks are red, and held at once (no push) when the note names a PR whose checks are red: raised again as it was when a checks wait on the PR resolves passed | its PR wait resolving as merged, abandoned or closed, a `Landed` note, or the PR's checks failing (`pr.checks-failed`, resolved as `held: …`). A PR wait without the note raises nothing, because auto landing needs nobody |
|
|
262
|
-
| `npm-otp` | a `Handoff · npm-otp` note (the `npm stage approve …` on its heading is the command), or an `npm` wait | the version on its tag, or a `Resumed` or `Landed` note |
|
|
305
|
+
| `npm-otp` | a `Handoff · npm-otp` note (the `npm stage approve …` on its heading is the command), or an `npm` wait: the `npm-stage` release kind's gate | the version on its tag, or a `Resumed` or `Landed` note |
|
|
263
306
|
| `operator`, `release`, `human`, `scope`, `blocked`, `abandoned`, `failed` | a Handoff note of that kind | any later note on the item (such as `Resumed`), or `conduct done` |
|
|
264
307
|
| `permission`, `stall` | `worker.stalled` (no idle `stall` while the worker's Handoff item with nothing to watch is open; see Workers) | `worker.stall-cleared`, a park or resume, a new wait or Handoff (stall only), or a daemon restart (it is raised again if it persists) |
|
|
265
308
|
| `stall` (park failing) | `worker.park-failed`, with `conduct attach` and `conduct retire` | a park, the session stopping unexpectedly (which raises `failed`), its retirement, or a daemon restart (the park is tried again, and fails again if it still does). A new wait doesn't resolve it. A retire stops the session the same way, so when the pid outlives `claude stop`, end that process: the next sweep finds the session gone |
|
|
@@ -269,37 +312,52 @@ An item resolves itself when its condition is seen, and records what resolved it
|
|
|
269
312
|
| `login` | `provider.login-failed`: a project's provider login stopped working (see Accounts), once per provider and account however many projects and calls find it, with the command that signs in again | the login working again, found by the daemon's re-probe, any call, or a `login` wait (`provider.login-restored`), `conduct done`, or a daemon restart (the spell starts over) |
|
|
270
313
|
| `runaway` | a worker passing `inbox.runawayTokens` (1.5M) input, output and cache-write tokens in all, once per worker | `conduct done`, or its retirement |
|
|
271
314
|
| `context` | `worker.compact-failed`: the daemon couldn't compact a worker whose context passed the `compact-on-park` rule (see Workers), while one is open, with `conduct attach` (run `/compact` in it) and `conduct retire` | the session compacting (`worker.compacted`), `conduct done`, or its retirement |
|
|
272
|
-
| `release`, `queue-drained` | the scheduler's default rules (see Scheduler): `ready-to-release` raises `release` with the profile's `release.handoff` text
|
|
315
|
+
| `release`, `queue-drained` | the scheduler's default rules (see Scheduler): `ready-to-release` raises `release` with the profile's `release.handoff` text | `conduct done` |
|
|
273
316
|
| `triage` | the scheduler's default rule `triage`: an item with the profile's triage label, once per item (see Scheduler) | a read of its project that no longer shows the label on the item (removed, or the item closed or left the queue), in `scheduler.mode` `on`; or `conduct done`, after which it is raised again the next day while the label stays. Once resolved, a label put back raises it again at once |
|
|
274
317
|
| `console` | the console's finding on an escalation, with `conduct escalations done <id> --ask` (see Console) | `conduct done` |
|
|
275
318
|
| `console` (no console open) | escalations wait and no console has followed the stream for 30 s, with `conduct console` and `conduct escalations` (see Console) | a console opening, the last escalation handled, or `conduct done` |
|
|
276
319
|
|
|
277
|
-
|
|
320
|
+
The daemon never approves prod itself. When a worker is retired, its open items resolve with it.
|
|
278
321
|
|
|
279
322
|
For an item raised from a note:
|
|
280
323
|
|
|
281
324
|
- The commands are the inline code on the note's heading and the lines of its shell-tagged fenced code blocks (`bash`, `sh`, `shell`, `powershell`, `pwsh`, `ps1`, `cmd`, `console`). An untagged block is output, not a command.
|
|
282
325
|
- The links are the comment's URL, named `<key> note`, and every URL in the note. A URL a Markdown link gives (`[registry PR #539](…)`) takes that link's text as its label; the others are named from their shape.
|
|
283
326
|
|
|
284
|
-
`conduct approve
|
|
327
|
+
`conduct approve ENG-312` (or the inbox id written `#12`; a bare number is refused, since it could mean an issue) posts `approve` on the item through the tracker, or `approve: <text>` with `--text`. It uses the same key as notes, so the comment carries the operator's name and counts as their answer. `conduct revise ENG-312 "<text>"` posts `revise: <text>`; every word after the item is the text, quoted or not. Either one then:
|
|
285
328
|
|
|
286
329
|
1. resolves the plan item and the worker's open `approval` wait;
|
|
287
|
-
2. has the session manager resume a parked worker with `[conductor] event: approval
|
|
330
|
+
2. has the session manager resume a parked worker with `[conductor] event: approval ENG-312: approve HH:MM`. The worker reads the comment on the tracker before it acts.
|
|
288
331
|
|
|
289
332
|
If the tracker refuses the comment, nothing is resolved. Only a plan takes approve or revise; `conduct done <id>` resolves any item.
|
|
290
333
|
|
|
334
|
+
An approved plan has no inbox item, so `conduct revise ENG-312 "<text>"` (and the console's `/revise`) with no plan of the item in the inbox revises the item's approved plan instead (ATK-664). The daemon posts `revise: <text>` the same way and then does at once what its 5-minute revise sweep would: it records the revise once for the Plan, and resolves every open wait of the item's parked worker with `plan ENG-312 revised: re-plan …`, so the worker resumes and re-plans. Once the PR has merged it wakes nobody: the revise is a follow-up PR, which the worker learns of at its next note. A running worker's next note is held back (exit 4), as for any revise of an approved plan. An item whose newest Plan isn't approved, or that has none the daemon saw, is refused before anything is posted. The daemon records only the revise the item's brief shows, which it reads up to three times, 2 s apart, since a tracker's read can lag its write. That way the worker's next note, which reads the brief too, is held back by the same revise. When the brief doesn't show the revise yet, can't be read, or the record fails, nothing is recorded, `conduct revise` says why, and the sweep records it on its next pass.
|
|
335
|
+
|
|
291
336
|
## Notifications
|
|
292
337
|
|
|
293
|
-
The notifier routes each new inbox item to the phone, on
|
|
338
|
+
The notifier routes each new inbox item to the phone, or anywhere else that takes a webhook, on three channels:
|
|
294
339
|
|
|
295
340
|
- **Remote Control:** the daemon appends `notify.requested` (`channel: "remote-control"`), and the conductor mod in the console pushes it with PushNotification. It reaches the phone only while the console is connected to Remote Control (see Mod). On Claude Code 2.1.296 these pushes are reported sent but never reach the phone (ATK-359), so the built-in `default` and `focus` also send to Pushover until a Claude Code release fixes it.
|
|
296
|
-
- **Pushover:** sent from the daemon. The keys are `PUSHOVER_TOKEN` and `PUSHOVER_USER`, or else the
|
|
341
|
+
- **Pushover:** sent from the daemon. The keys are `PUSHOVER_TOKEN` and `PUSHOVER_USER`, or else the credential `conductor-pushover` in the machine's secret store (`notify.pushover.credential`; see Accounts): its **user name is your Pushover user key** and its **password is the application's API token**. Save it with `cmdkey /generic:conductor-pushover /user:<user key> /pass` on Windows, `security add-generic-password -s conductor-pushover -a <user key> -w` on macOS, or `secret-tool store --label=conductor-pushover service conductor target conductor-pushover user <user key>` on Linux; each asks for the token. On macOS and Linux, delete an older item first (`conduct project check` prints the line that does). A credential saved while the daemon runs is read on the next push. The keys are never logged or stored. Only `~/.conductor` falls back to `conductor-pushover`: a daemon on another `CONDUCTOR_HOME` (a scratch home for a test) reads a credential only when its `config.json` names `notify.pushover.credential`, so it pushes to your phone only with that or with `PUSHOVER_TOKEN` and `PUSHOVER_USER` in its environment (ATK-506).
|
|
342
|
+
- **Webhook:** sent from the daemon, as one `POST` of JSON per push to `notify.webhook.url` in `config.json` (`http` or `https`). It is off until that is set: `conduct notify` shows it `inactive`, a test push to it fails with that reason, and `config.json` is refused while one of its profiles sends to it. `notify.webhook.credential` names a credential in the machine's secret store whose password goes out as `Authorization: Bearer <password>`, saved as Pushover's is (any user name); with one, the URL must be `https` or on this machine (`localhost`, `127.x.x.x`). `notify.webhook.timeoutSeconds` is 15 by default. Any 2xx counts as delivered; a redirect isn't followed, and counts as a refusal (set the URL it points to). A refusal or no answer marks the channel `failing`, and the push is tried again as a failed Pushover push is. Errors name only the URL's host, since a hook URL often carries its own secret. The body:
|
|
343
|
+
|
|
344
|
+
```jsonc
|
|
345
|
+
{
|
|
346
|
+
"event": "raised", // raised, reminder, summary (quiet hours), rule (a rule's notify) or test
|
|
347
|
+
"title": "conductor · ENG-5 npm-otp",
|
|
348
|
+
"text": "ENG-5 · approve the npm stage", // the push in one line, as the other channels show it
|
|
349
|
+
"link": "https://…", // its first link, or null
|
|
350
|
+
"project": "web",
|
|
351
|
+
// the inbox item whole for raised and reminder, null otherwise
|
|
352
|
+
"inbox": { "id": 12, "kind": "npm-otp", "item": "ENG-5", "project": "web", "text": "…", "commands": ["npm stage approve … --otp=<code>"], "links": [{ "label": "run 7", "href": "https://…" }], "raisedAt": "2026-10-18T12:00:00.000Z", "worker": 3 }
|
|
353
|
+
}
|
|
354
|
+
```
|
|
297
355
|
|
|
298
356
|
**Profiles** pick the channels. A profile is a list of rules, and the first rule that matches an item says where it goes; when none matches, the item stays in the inbox, unpushed. Three are built in, and `notify.profiles` in `config.json` replaces one by name or adds more:
|
|
299
357
|
|
|
300
358
|
```jsonc
|
|
301
359
|
{
|
|
302
|
-
"operator": { "name": "
|
|
360
|
+
"operator": { "name": "Sam" }, // the one person this daemon serves; messages name them ("the operator" by default)
|
|
303
361
|
"quietHours": { "from": "22:00", "to": "07:00" }, // local; omit for none
|
|
304
362
|
"notify": {
|
|
305
363
|
"profile": "default", // the profile when none is switched to
|
|
@@ -308,7 +366,7 @@ The notifier routes each new inbox item to the phone, on two channels:
|
|
|
308
366
|
"away": [{ "to": ["pushover"] }], // built in
|
|
309
367
|
"focus": [{ "kinds": ["npm-otp", "permission", "failed", "conflict", "operator", "login"], "to": ["remote-control", "pushover"] }], // built in
|
|
310
368
|
"workday": [
|
|
311
|
-
{ "projects": ["
|
|
369
|
+
{ "projects": ["portal"], "days": ["sat", "sun"], "to": [] }, // keep these off every channel
|
|
312
370
|
{ "kinds": ["npm-otp"], "to": ["pushover", "remote-control"], "remind": { "afterMinutes": 10, "times": 5 } },
|
|
313
371
|
{ "hours": "08:00-18:00", "to": ["remote-control"] }
|
|
314
372
|
]
|
|
@@ -319,12 +377,12 @@ The notifier routes each new inbox item to the phone, on two channels:
|
|
|
319
377
|
|
|
320
378
|
A rule matches on `kinds` (inbox kinds), `projects`, `hours` (local, `HH:MM-HH:MM`, may run past midnight) and `days` (`sun` … `sat`, the day when the item is routed, so 01:00 on Saturday is `sat` even inside a Friday `22:00-02:00`); a rule with none of them matches everything. `conduct notify profile away` switches at once, without a restart, and the profile survives one. `--until 17:00` (the next 17:00), `--until 2026-10-12` (local midnight) or `--until 2026-10-12T09:00` hands back to the profile that was in force before. Switching to config.json's own `notify.profile` without `--until` hands the choice back to config.json. Each switch is a `notify.profile` event.
|
|
321
379
|
|
|
322
|
-
- **Quiet hours** (`quietHours`): pushes are held (`notify.routed` with `outcome: "held"`). When they end, each channel gets one summary of the held items still open, such as `2 waiting since quiet hours:
|
|
380
|
+
- **Quiet hours** (`quietHours`): pushes are held (`notify.routed` with `outcome: "held"`). When they end, each channel gets one summary of the held items still open, such as `2 waiting since quiet hours: ENG-1 npm-otp, ENG-2 plan; 1 resolved meanwhile`, routed by the profile then in force. `conduct notify test` pushes at any hour.
|
|
323
381
|
- **Dedupe:** the same project, item, kind and text pushed within `notify.dedupeMinutes` (30) to the channels it would go to now isn't pushed again; a newer note that replaces an item is the usual case. Its reminders carry on from the first push, on the newer item only. A copy that never reached the phone (held, or a failed push) gives way to the newer one.
|
|
324
382
|
- **Reminders:** an open item is pushed again `notify.remind.afterMinutes` (30) after its last push, at most `notify.remind.times` (2) times, as `reminder 1/2: …`, on the channels the profile in force then gives it. A rule's own `remind` overrides that. A resolved item is never reminded. A first push that reached no channel (Pushover down, no credential) is tried again at the same interval without using up a reminder.
|
|
325
|
-
- **Events:** `notify.routed` (the profile, the rule, the channels, `sent`, `held`, `deduped` or `none`), `notify.requested`, `notify.sent` and `notify.failed` (Pushover), `notify.channel` (a channel's state changed), `notify.profile`.
|
|
383
|
+
- **Events:** `notify.routed` (the profile, the rule, the channels, `sent`, `held`, `deduped` or `none`), `notify.requested`, `notify.sent` and `notify.failed` (Pushover and the webhook), `notify.channel` (a channel's state changed), `notify.profile`.
|
|
326
384
|
|
|
327
|
-
`conduct notify` shows the profile, quiet hours and the last state of each channel: Pushover from
|
|
385
|
+
`conduct notify` shows the profile, quiet hours and the last state of each channel: Pushover and the webhook from their own answers (the webhook is `inactive` with no `notify.webhook.url`), Remote Control from what the mod reports back from PushNotification after each push: `ok` (Claude Code reported it sent; see ATK-359 above for why that doesn't prove it reached the phone), `inactive` (Remote Control off, or mobile pushes off in Claude Code's config), `skipped` (not sent to the phone on purpose, such as while you are at the terminal: `user_present`) or `failing` (the call failed), each with its reason.
|
|
328
386
|
|
|
329
387
|
## Rules
|
|
330
388
|
|
|
@@ -341,14 +399,14 @@ Rules say what the conductor does when something happens (design note, section 9
|
|
|
341
399
|
]
|
|
342
400
|
```
|
|
343
401
|
|
|
344
|
-
- **Sources, in order:** the item's project's `rules
|
|
345
|
-
- **when:** `and`, `or`, `not` and parentheses over comparisons (`==`, `!=`, `<`, `<=`, `>`, `>=`, `in [a, b]`, `<label> in item.labels`, `Started in item.phases`) and events. An event rule fires when one of its events happens: `note`, `plan`, `wait.resolved`, `run.failed` (a failed launch, a session that stopped unexpectedly, or a PR's red checks: `pr.checks-failed`), `worker.idle`, `worker.exited`, `worker.permission`, `worker.waiting`, `worker.parked` (a park; the session manager also asks it of each parked worker on a sweep, for `compact`), `inbox.raised`, `pr.comment`, `item.unblocked`, `item.closed`, `provider.login-failed` (a project's login lost, once per spell; see Accounts), `queue.pick`, `queue.drained`, `queue.ready-to-release`, `queue.triage`. A rule without one is a state rule, checked on each scheduler tick against each wait, inbox item, worker or project, or once for the machine, by the fields it reads. Fields: `project`, `item.id/area/labels/tier/priority`, `item.phases` (the phases of every note the item has had, the one being posted too), `plan.tier/landing`, `note.phase/kind`, `wait.kind/age`, `inbox.kind/age`, `worker.retries/phase`, `worker.context` (the session's context in tokens, see Workers), `spend.worker/item/project/today` (input, output and cache-write tokens, without cache reads: the worker's and the item's in all, the project's and the machine's today), `time`, `day`, `notify.profile`. Values carry units: `15m`, `2h`, `500k`, `2M`, `22:00`, `saturday`.
|
|
402
|
+
- **Sources, in order:** the item's project's `rules`, its machine profile's (`~/.conductor/projects/<project>.json`, named `project.<project>.machine:<id>`) before its repo's (`.claude/conductor.json`, changed by PR, named `project.<project>:<id>`; see Projects), then `~/.conductor/rules.json`, then the built-in defaults: the conductor's own behavior written as rules (`launch`, `retire-closed`, `park-on-wait`, `resume-on-wait`, `compact-on-park`, `runaway`, `daily-cap`, `ready-to-release`, `queue-drained`, `triage`, which raises a new item that waits on the profile's triage label in the inbox, `conductor-handoff`, which escalates a worker's `Handoff · conductor` note to the console, and `hold-pre-start-handoff`, which adds the human label when a worker hands off as `human`, `operator`, `blocked` or `scope` before its `Started` note, so the scheduler never launches work no worker can do again; remove the label if the item should be picked later), and `release-pre-start-hold`, which takes that label off again when the worker clears the hold itself (its `Resumed`, or its `Started` after the hold), and leaves a human label the hold didn't add (ATK-575). A rule with an `id` replaces the rule with the same id further down, so a file holds only the differences, and a machine-profile rule replaces the repo's rule of its id (`"do": "none"` switches it off).
|
|
403
|
+
- **when:** `and`, `or`, `not` and parentheses over comparisons (`==`, `!=`, `<`, `<=`, `>`, `>=`, `in [a, b]`, `<label> in item.labels`, `Started in item.phases`) and events. An event rule fires when one of its events happens: `note`, `plan`, `wait.resolved`, `run.failed` (a failed launch, a session that stopped unexpectedly, or a PR's red checks: `pr.checks-failed`), `worker.idle`, `worker.exited`, `worker.permission`, `worker.waiting`, `worker.parked` (a park; the session manager also asks it of each parked worker on a sweep, for `compact`), `inbox.raised`, `pr.comment`, `item.unblocked`, `item.closed`, `provider.login-failed` (a project's login lost, once per spell; see Accounts), `queue.pick`, `queue.drained`, `queue.ready-to-release`, `queue.triage`. A rule without one is a state rule, checked on each scheduler tick against each wait, inbox item, worker or project, or once for the machine, by the fields it reads. Fields: `project`, `item.id/area/labels/tier/priority`, `item.phases` (the phases of every note the item has had, the one being posted too), `item.labelled-by` (the ids of the rules whose label add still stands on the item: a label they added, not one it had already, that no rule removed since), `plan.tier/landing`, `note.phase/kind`, `wait.kind/age`, `inbox.kind/age`, `worker.retries/phase`, `worker.context` (the session's context in tokens, see Workers), `spend.worker/item/project/today` (input, output and cache-write tokens, without cache reads: the worker's and the item's in all, the project's and the machine's today), `time`, `day`, `notify.profile`. Values carry units: `15m`, `2h`, `500k`, `2M`, `22:00`, `saturday`.
|
|
346
404
|
- **do:** `launch`, `retire`, `park`, `resume worker`, `retry`, `pause launches [in <project>] [until <when> | for <duration>]`, `pause all launches …`, `unpause launches`, `focus [<project>] weight|slots <n> until <when>`, `auto-approve now`, `auto-approve after <duration>`, `inbox <kind> ["<text>"]`, `remind operator`, `notify <channel> ["<text>"]`, `notify profile <name> [until <when>]`, `label add|remove human|triage|shipped-open`, `flag shipped-open`, `escalate console "<ask>"`, `none`. One string, or a list.
|
|
347
405
|
- **Guardrails:** rules act inside Area locks, slots, quiet hours and config.json's limits. A rule that would go past one is refused when it loads: `focus … slots` over `scheduler.slots`, an override with no end or one over 14 days, `retire` on anything but `item.closed`, `launch` outside `queue.pick` / `item.unblocked`, `park` outside `worker.waiting`, `resume` outside `wait.resolved` / `pr.comment`, `compact` outside `worker.parked`, `retry` outside `run.failed`. `merge`, `push`, `publish`, `approve`, `delete` and `run` aren't in the vocabulary: rules never merge, push, publish, approve prod, delete or run commands, and the daemon writes to a code host only when the owning worker asks (see Writes).
|
|
348
|
-
- **Reload:** saving `rules.json` or a project's profile reloads its rules.
|
|
349
|
-
- **Checking:** `conduct rules` lists the rules in force (`project.
|
|
406
|
+
- **Reload:** saving `rules.json` or a project's machine profile reloads its rules, and so does a fetch of its clone that brings another `.claude/conductor.json` on the base branch. A profile changed either way, or while the daemon was down, is taken whole: the daemon stores it and logs `project.updated` with the fields that `changed`, and the scheduler, watchers and the rest use it from then on. A project keeps the name its folder gives it: the repo's `name` is only what `project add` takes when no `--name` is given, and a machine profile that names another project is refused. To rename one, `conduct project remove <old name>`, then `conduct project add <url> --name <new name>` (in that order: with both registered, both would read the same queue and could launch the same item). The remove drops the old registration and its queue read, resolves its open inbox items (the refusal's among them) and logs `project.removed`, on which the daemon drops its rules and its watch of the file. It is refused while the project has workers in flight, and works without the daemon. A file the conductor refuses leaves its last good rules (for a profile, the whole last good profile) in force, logs `rules.rejected` and raises a `failed` inbox item until it loads again (`rules.loaded`), including after a restart. `conduct project add` refuses a profile whose rules don't load, with or without the daemon.
|
|
407
|
+
- **Checking:** `conduct rules` lists the rules in force (`project.web:#2`, `machine:runaway`, `default:launch`) and what replaces what. `conduct rules test <file>` dry-runs a sample, such as `{ "event": "pr.comment", "project": "web", "fields": { "wait.kind": "merge", "wait.age": "3h" } }` (leave out `event` for a state rule), and shows what fires, each action's guardrail, and why every other rule of the trigger didn't. Both read the files directly when the daemon isn't running.
|
|
350
408
|
|
|
351
|
-
- **Why:** every action the conductor takes is an event that names its rule, directly (`rule`) or through the event that caused it (`data.cause`). `conduct why <event id>` follows that chain back and shows the rule as it fired; `conduct why
|
|
409
|
+
- **Why:** every action the conductor takes is an event that names its rule, directly (`rule`) or through the event that caused it (`data.cause`). `conduct why <event id>` follows that chain back and shows the rule as it fired; `conduct why ENG-312` does it for the item's newest actions.
|
|
352
410
|
|
|
353
411
|
**How actions are carried out.** Every action is a `rule.fired` event that names its rule, then `rule.done` or `rule.failed`.
|
|
354
412
|
|
|
@@ -366,56 +424,87 @@ What each action does:
|
|
|
366
424
|
- `escalate console` emits `console.escalated`, which wakes the console's model; the console reports back with `conduct escalations done` (see Console).
|
|
367
425
|
- `retry` launches a failed launch again, or wakes a session that stopped unexpectedly with the fact. It does this once per item, whatever the rule says: a second failure stays in the inbox. `worker.retries` counts the earlier tries.
|
|
368
426
|
- `auto-approve after N` pushes "auto-approves at HH:MM" where plans go, and the plan's inbox item says the same. At the deadline, if the plan still waits (no `revise:`, no newer Plan), it posts `approve: auto-approved by the conductor: rule "<id>", no revise by HH:MM` as the key's owner, which the worker's approval wait reads. A deadline that falls in quiet hours (or a plan posted in them, or one that comes due in quiet hours set since) moves to their end plus N, so the quiet-hours summary reaches you first and you still get N to revise. If the comment can't be posted, the inbox item says the plan waits on you again.
|
|
369
|
-
- `auto-approve now` approves the plan as its note arrives: no inbox item, no push. `conduct note` asks the rules before it records the Plan and puts the rule on `note.posted` (`autoApprove`), so the inbox raises nothing. The conductor then posts `approve: auto-approved by the conductor: rule "<id>", at once` as the key's owner and records `plan.auto-approved`, and the worker's approval wait resolves as soon as it is added (or as the comment lands). If the comment can't be posted (`plan.auto-approve-failed`), the plan's inbox item is raised and it waits on you. You can still write `revise: …` on the item: the worker's next `conduct note` reads the item, finds it after the approval, records `plan.revised`, posts nothing and exits 4 with your words, and the worker re-plans (see Notes). A Handoff note and a new Plan still go through, and an `approve` after the revise takes it back. Once you have revised a plan of an item, its later plans wait on you whatever the rules say, `auto-approve after` included. The same holds after a plan you approved yourself. A worker that posts no note meanwhile (parked, or only Handoffs) still has the revise counted: every 5 minutes the daemon reads the items whose newest Plan is approved and records `plan.revised` for one with a `revise:` after its approval.
|
|
427
|
+
- `auto-approve now` approves the plan as its note arrives: no inbox item, no push. `conduct note` asks the rules before it records the Plan and puts the rule on `note.posted` (`autoApprove`), so the inbox raises nothing. The conductor then posts `approve: auto-approved by the conductor: rule "<id>", at once` as the key's owner and records `plan.auto-approved`, and the worker's approval wait resolves as soon as it is added (or as the comment lands). If the comment can't be posted (`plan.auto-approve-failed`), the plan's inbox item is raised and it waits on you. You can still write `revise: …` on the item: the worker's next `conduct note` reads the item, finds it after the approval, records `plan.revised`, posts nothing and exits 4 with your words, and the worker re-plans (see Notes). A Handoff note and a new Plan still go through, and an `approve` after the revise takes it back. Once you have revised a plan of an item, its later plans wait on you whatever the rules say, `auto-approve after` included. The same holds after a plan you approved yourself. A worker that posts no note meanwhile (parked, or only Handoffs) still has the revise counted: every 5 minutes the daemon reads the items whose newest Plan is approved and records `plan.revised` for one with a `revise:` after its approval. When that pass records one and the item's PR hasn't merged, it also resolves the open waits of the item's worker with `plan <item> revised …: re-plan`. The daemon then resumes a parked worker with it at once, so the worker re-plans instead of sleeping until its PR settles (ATK-462). After the merge it wakes nobody: the revise becomes a follow-up PR, which the worker learns of at its next note. The PR's auto-complete stays armed meanwhile: the brake on the PR itself is a PR comment. If the daemon stopped between recording the Plan and posting the approval, the approval wait raises nothing, and the next rules tick approves it once the note is 2 minutes old.
|
|
370
428
|
- No default rule auto-approves. A Plan note's `**Size tier: S**` (or the older `**Size: S**`) gives `plan.tier` (and `item.tier` later), and its `**Landing:** auto|review` gives `plan.landing`: work-item lands its review paths (infra, pipelines, auth) and review tiers in `review`, so `plan.landing == auto` keeps them out of an auto-approve rule. A Plan that names neither fails the comparison and waits for you.
|
|
371
429
|
- `launch` on `item.unblocked` brings the scheduler's next tick forward.
|
|
372
|
-
- `launch` and `retry` start sessions, so they wait for `scheduler.mode` `on`, as the scheduler's own launches do. In `
|
|
430
|
+
- `launch` and `retry` start sessions, so they wait for `scheduler.mode` `on`, as the scheduler's own launches do. In `off` they are logged, not done. Every other action acts in any mode, except those the scheduler's own tick fires (`item.closed`, `queue.*`), which `off` only logs on a queue read on demand.
|
|
373
431
|
|
|
374
432
|
The defaults `runaway` and `daily-cap` are the budget: a runaway inbox item past `inbox.runawayTokens` for one worker, and no new launches past `scheduler.dailyTokens` today until midnight. Both count tokens without cache reads, as every `spend.*` field does and as `conduct status` shows them. On that measure a worker's median to 2026-10-14 was 442k and its most 1.68M, and a busy day ran 16M to 26M (ATK-464).
|
|
375
433
|
|
|
376
434
|
## Scheduler
|
|
377
435
|
|
|
378
|
-
The scheduler
|
|
436
|
+
The scheduler is the work loop's tick, in the daemon (design note, section 8). Every `scheduler.tickSeconds` (120), and 10 s after a phase note, a retire, a failed launch or a cleared inbox item, it does three things.
|
|
379
437
|
|
|
380
|
-
1. **Read.** It reads each conducted project's queue from its tracker: for Linear, the team's projects with a priority, the Area labels, every open item with its blockers, and the in-flight items. Each read is one request (plus pages), within the Linear watcher's rate budget. For ADO Boards, the profile's saved query (see ADO Boards), within the ADO watcher's budget.
|
|
381
|
-
2. **Plan.** It works out the picks across projects, with
|
|
382
|
-
- **Order:** the tracker project's priority, then the conductor project's `weight` (profile, or `conduct focus --weight`), then the item's priority, then when it was created.
|
|
438
|
+
1. **Read.** It reads each conducted project's queue from its tracker: for Linear, the team's projects with a priority (or the label of a label queue), the Area labels, every open item with its blockers, and the in-flight items (see Linear). Each read is one request (plus pages), within the Linear watcher's rate budget. For ADO Boards, the profile's saved query (see ADO Boards), within the ADO watcher's budget.
|
|
439
|
+
2. **Plan.** It works out the picks across projects, with an order and a reason for each item. A replay of a recorded Linear read is in the tests.
|
|
440
|
+
- **Order:** the tracker project's priority, then the conductor project's `weight` (profile, or `conduct focus --weight`), then the item's priority, then the tracker's own order within that priority (Linear's drag order, a Boards query's or a GitHub Issues queue's order), then when it was created.
|
|
383
441
|
- **Areas:** one item per Area per project until its PR merges. An Area is held by every worker in flight and every started item, and by the Areas it covers or is covered by. An item whose Plan lands in `auto` holds it no more once its PR has merged, so the next item in its Area starts from a base branch that has its work, while it waits on the dev check or the npm OTP; `conduct queue` and `conduct why` name the item it shares the Area with. The profile's `areaRelease` sets when (ATK-555):
|
|
384
442
|
- `merge` (the default): when the code host (Azure DevOps or GitHub) reports the PR merged, or at the item's `Landed` note. The PR is its worker's (from `conduct pr create` or its newest `PR opened` note), else, with no worker in flight, the one its newest `PR opened` note names. Each tick reads it in one request (the PR alone, without its checks or threads), and a merged PR isn't read again. A PR still open, one closed without merging, one the daemon can't read (logged once) or an item with no PR known keeps the Area held.
|
|
385
443
|
- `pr`: as soon as its newest lifecycle note is `PR opened` or `Landed`, so the next item starts while the first PR still waits on CI and the merge (ATK-412).
|
|
386
444
|
|
|
387
445
|
A `review` plan, or an item the daemon has no notes for, holds its Area until the item closes.
|
|
388
446
|
- **Not picked:** items labelled with the profile's human label, and items whose blockers are still open, which wait (pending, or "not being worked" when nothing will clear them).
|
|
389
|
-
- **Triage:** a profile's `tracker.triageLabel` (such as `triage`, ATK-277) marks a new item a worker filed that
|
|
390
|
-
- **Release waits:** a profile's `tracker.releaseWait: { "label": "waits-release", "shipped": "release
|
|
391
|
-
- **Slots:** `scheduler.slots` (4) across projects and the profile's `slots` per project
|
|
447
|
+
- **Triage:** a profile's `tracker.triageLabel` (such as `triage`, ATK-277) marks a new item a worker filed that the operator hasn't looked at. It is never picked (`labelled triage: new, waits for the operator to triage it`, not on its own; the label matches in any case, as Boards tags do), an item it blocks waits as blocked by an item nobody works on, and `queue.triage` raises it in the inbox. Remove the label to queue it, or swap it for the human label. A rule's `label add triage` adds this label. Without the setting the label means nothing to the queue. A conductor older than the setting refuses a profile that has it (the profile is strict), so set it only once every daemon and `conduct` that reads the profile has been upgraded.
|
|
448
|
+
- **Release waits:** a profile's `tracker.releaseWait: { "label": "waits-release", "shipped": "release · Shipped" }` marks work that needs a dependency released, not only merged (a CLI change on npm `latest`, a prod deploy). It works on every tracker: the label is a Linear label, a Boards tag or a GitHub Issues label (matched without regard to case on the last two), and the blockers are Linear's blocking relations, Boards Predecessors or GitHub's issue dependencies. An item with that label waits, once its open blockers have closed, until every Done blocker has a comment whose first line starts with the `shipped` text, under any heading or bold (`waits for ENG-311 to ship`), and starts on the first tick after. On Boards that is a discussion comment, markdown or the web UI's HTML. A comment that only quotes the text doesn't count. A blocker still open in another team, or in a Boards state the profile doesn't map, waits the same way; a canceled one (Removed on Boards, closed as not planned or a duplicate on GitHub) holds nothing. A Boards Predecessor in another ADO project, or a GitHub blocker in another repo, has its comments read there. A blocker whose comments can't be read (a 403, a 404, a provider error) holds and is asked again next read, while the rest of the read goes on; so does a Boards Predecessor ADO doesn't return at all (deleted, or in a project the account can't read). A blocker that shipped with no such note holds until you add the note or remove the label. With the label and no blocker at all it waits until you act (`labelled waits-release with no blocker to wait on`), for something outside the tracker such as a Claude Code release. Such an item doesn't keep its tracker project from `queue.ready-to-release`, since the release is what it waits for. On Linear the read asks about the Done blockers in one more request, only when a labelled item has one. On Boards and GitHub Issues it reads the comments of a Done blocker that has any, and again only once its comment count or revision moves (its comment count or update time on GitHub), as a new comment does; on GitHub a labelled issue also reads its blocker list every read, since GitHub's summary counts only open blockers. Without the setting, a Done blocker never holds anything.
|
|
449
|
+
- **Slots:** `scheduler.slots` (4) across projects and the profile's `slots` per project, or `conduct focus --slots`, never more than the global ones. Slots count running workers only: a parked worker keeps its Area and frees its slot.
|
|
392
450
|
- **Holds:** quiet hours and `conduct pause` turn picks into waits that say why.
|
|
393
451
|
3. **Act.** It runs the rules on what it found: `queue.pick` → `launch` (default `launch`), `item.closed` → `retire` (default `retire-closed`; a refused retire raises an inbox item once, except one refused while the worker's turn runs or its session starts, which is tried again each tick and raised only after 15 minutes; a partial retire is tried again after 15 minutes, and after 3 in a row it raises one inbox item that names the failed steps and any worktree left on disk, which the conductor never deletes, and is tried once a day), `queue.drained` and `queue.ready-to-release` → an inbox item once a day, `queue.triage` → a `triage` inbox item per item (default `triage`), resolved once the label goes. Each action is a `rule.fired` event, then `rule.done` or `rule.failed`. A launch that failed isn't tried again while its inbox item is open.
|
|
394
452
|
|
|
395
453
|
`scheduler.mode` decides how far it goes:
|
|
396
454
|
- **`on`** (the default): it acts.
|
|
397
|
-
- **`
|
|
398
|
-
- **`off`:** it doesn't run; `conduct queue --fresh` still works out a plan on request.
|
|
455
|
+
- **`off`:** no timed queue, so nothing is launched or retired and the queue raises no inbox items. A queue read on demand (`conduct queue --fresh`, or the first `conduct queue`) works out a plan and logs each action it would take once, and does none of them.
|
|
399
456
|
|
|
400
457
|
`conduct queue` shows the last plan (`--fresh` reads again): slots, holds, picks with their Area, and why each other item waits. A change of plan is a `scheduler.planned` event: its picks, the number waiting, `waitingBy` (how many wait for each reason: `in-flight`, `human`, `blocked`, `release`, `area`, `held`, `slot`) and `areaHolders` (each Area that holds an item up, with its project, its holder and the items waiting on it). In `GET /queue`, each waiting item carries the same `kind`, and an Area wait its `holder`. A project whose read fails picks nothing that tick and logs `scheduler.read-failed` once, then `scheduler.read-recovered`. An item whose last open blocker closed is an `item.unblocked` event.
|
|
401
458
|
|
|
402
|
-
**Overrides** end on their own: `conduct pause
|
|
459
|
+
**Overrides** end on their own: `conduct pause web --until 17:00`, `conduct pause --until tomorrow` (every project), `conduct unpause web`, `conduct focus web --slots 3 --until monday`. `--until` takes `17:00` (the next one), `tomorrow`, a day, or a date and time. Each is an `override.set` event, which `conduct why` attributes to the command, and they show in `conduct rules`.
|
|
460
|
+
|
|
461
|
+
## Projects
|
|
462
|
+
|
|
463
|
+
A conducted project is a clone the daemon owns (design note v2, sections 2 and 7; ATK-527). Everything of it lives under `~/.conductor/projects`:
|
|
403
464
|
|
|
404
|
-
|
|
465
|
+
| Path | Holds |
|
|
466
|
+
|---|---|
|
|
467
|
+
| `<name>.json` | its machine profile: what this machine says about the project, which wins over the repo's |
|
|
468
|
+
| `<name>/repo` | the clone of its remote, which the daemon fetches and adds worktrees to |
|
|
469
|
+
| `<name>/worktrees/<item>` | a worker's worktree (see Workers) |
|
|
470
|
+
|
|
471
|
+
**Your first conducted project.** `conduct init` takes a repo from a checkout to a passing `conduct project check` with no hand-written JSON (ATK-528). The repo is on GitHub or Azure DevOps and has at least one commit on its base branch.
|
|
472
|
+
|
|
473
|
+
1. Install the conductor (see On PATH) and sign in to the code host and tracker (see Accounts).
|
|
474
|
+
2. In a checkout of the repo, run `conduct init`. It shows what it detected:
|
|
475
|
+
- the code host from `origin`;
|
|
476
|
+
- CI as the code host's own (a hint when the pipeline files are another's; for a GitHub repo with Azure Pipelines files, how to set `ci` to them);
|
|
477
|
+
- the base branch from `origin/HEAD`;
|
|
478
|
+
- `setup` from the lockfile at the root (`pnpm install --frozen-lockfile`, `npm ci`, `yarn install`, `bun install`, `uv sync`, `poetry install` or `go mod download`);
|
|
479
|
+
- a `verify-*` skill in `.claude/skills`;
|
|
480
|
+
- the item key most recent commit subjects carry.
|
|
481
|
+
|
|
482
|
+
Then it asks for the rest, each detected value the default: the name, the base, the tracker (the code host's own issues or Boards, or Linear), its key, team or query, `setup` and the verify skill. `--yes` asks nothing and refuses, with each one's flag, what it couldn't detect. Without `--yes` it needs a terminal to ask in: node sees none in Git Bash's mintty, so use Windows Terminal there, or `--yes`.
|
|
483
|
+
3. It writes a starter profile to `~/.conductor/projects/<name>.json`: the code host, CI, base, tracker, `conductor:work-item` and the verify skill, 2 slots, the plan and merge gates, a release handoff and `setup`, with the `conductor` minimum its fields need. `--shared` writes `.claude/conductor.json` in the checkout instead, which the conductor reads only on `origin/<base>`: commit it, push it and run `conduct init` again. A profile file that is there is never overwritten, and a repo profile on `origin/<base>` that names the project means nothing is written; one only in the working tree stops `init` until it is pushed.
|
|
484
|
+
4. It runs `conduct project add` on the checkout. For a new clone it prints the trust step: run `claude` in the clone once and accept the prompt. In a terminal it waits for Enter; with `--yes` it stops there.
|
|
485
|
+
5. It runs `conduct project check`. With no verify skill, the worktree check fails as a launch would, and `init` points at the conductor plugin's `/conductor:create-verification-skill`, which loads only with the plugin: run `claude --plugin-dir <the plugin>` in the checkout (`init` prints the folder), make the skill under the name the profile's `skills.verify` gives, commit it and push it.
|
|
486
|
+
6. Run `conduct init` again after each step: it keeps what is done, fetches the clone and checks again. On a project that is set up, it writes nothing and only fetches and checks. Once the check passes, give the tracker's projects a priority (Linear) or label items `conduct` (GitHub Issues), and `conduct launch <item>`.
|
|
487
|
+
|
|
488
|
+
**Adding one.** `conduct project add <url | path> [--name <name>]` clones the remote into `<name>/repo`, in the CLI's own process, so its output shows and no API timeout applies. Given a path, it reads that checkout's `origin` and clones from the remote: the folder itself is never cloned, and nothing the conductor does touches it again. The name is `--name`, else the `name` the repo's `.claude/conductor.json` gives on its default branch; a repo with no profile needs `--name`. The clone is made in a staging folder first, so one that fails leaves nothing. Adding a project again fetches its clone (its origin must be the same remote) and updates the registration in place: its inbox, items and workers stay. Then the daemon registers it (or the store, with none running). Claude Code trusts a worktree by its repository, and nobody has opened a new clone in Claude Code yet, so `add` ends with the one step that trusts it: run `claude` in the clone once and accept the trust prompt, then `conduct project check <name>`.
|
|
489
|
+
|
|
490
|
+
**Its profile** comes in two layers, merged as the files are written, before the schema reads them:
|
|
405
491
|
|
|
406
|
-
|
|
492
|
+
- the **machine profile**, `<name>.json` (JSONC), optional beside a repo profile. A project with no repo profile keeps a whole profile here;
|
|
493
|
+
- the **repo profile**, `.claude/conductor.json` at `origin/<base>` in the clone, optional, changed by PR and shared by the team. `<base>` is the machine profile's `baseBranch`, else the remote's default branch (`origin/HEAD`); when the file there names another `baseBranch`, it is read once more at that one.
|
|
407
494
|
|
|
408
|
-
|
|
409
|
-
2. When they differ, it records the pins as pending (a `checkout.pending` event). With the main checkout on `<base>` and no local changes to `.atk-lock.json`, it then runs `git merge --ff-only origin/<base>`.
|
|
410
|
-
3. Once `HEAD` has the pins, whether by this pull or yours, it runs `atk install` for the pending assets. A `checkout.updated` event lists each `name old → new`. An install that fails or leaves drift stays pending, and the next check tries it again, in this daemon or the next.
|
|
495
|
+
Objects merge key by key and the machine's value wins; arrays (`ports`, `setup`, `approvers`, `gates`, …) are replaced whole. `rules` stack instead: the machine profile's first, then the repo's, then `rules.json`, then the defaults, so a machine rule replaces the repo's rule of its id (see Rules). Each layer's `conductor` minimum is checked on its own, and `protocol` on the merge. `name` is the registered one: a machine profile may repeat it, not change it.
|
|
411
496
|
|
|
412
|
-
|
|
497
|
+
`conduct profile <name>` prints the merged profile from the files and the clone as fetched last, one line per field with its value and its source (`machine`, `repo` or `default`), then each rule with its layer; `--json` gives `{ profile, sources }`. It works with or without the daemon.
|
|
498
|
+
|
|
499
|
+
**Keeping it current.** When the daemon starts and every `clones.fetchMinutes` (10) after, it runs `git fetch --quiet --prune origin` in each clone and then reads the profile again, so a changed `.claude/conductor.json` on the base branch is in force as a saved machine profile is (a watch on `<name>.json` takes that at once). A worktree create waits out a fetch that is running, and the fetches pass a clone by while a create works in it, so the two never write the clone's refs at once. A fetch can't prompt for credentials (`GIT_TERMINAL_PROMPT=0`) and gives up after 2 minutes. A failed fetch is logged once a spell, and the profile stays as it was. Nothing is pulled, installed or fast-forwarded anywhere else: a worktree's `setup` installs what its branch pins.
|
|
500
|
+
|
|
501
|
+
**From a project registered by its checkout** (before ATK-527): the daemon conducts such a row no longer. Its profile is refused with the step that moves it, `conduct project add "<that checkout>"`, which clones the checkout's origin and keeps the registration, and the scheduler launches none of its items meanwhile. Workers in flight keep their worktrees in that checkout, and retire from it: that retire is the one time the conductor runs git there (`git worktree prune`, and `git branch -D` of the worker's branch). A profile refusal of the project, whichever file it named, resolves once the project's profile loads.
|
|
413
502
|
|
|
414
503
|
## Skill trains
|
|
415
504
|
|
|
416
|
-
Each skill change ships as its own registry publish, and each publish is a GitHub PR that
|
|
505
|
+
Each skill change ships as its own registry publish, and each publish is a GitHub PR that the operator merges. A train turns several of them into one merge (ATK-285). A profile turns it on with `"train": { "repo": "<owner>/<repo>", "minimum": 3, "maxHours": 24 }`.
|
|
417
506
|
|
|
418
|
-
1. A worker publishes as before (
|
|
507
|
+
1. A worker publishes as before (its publish opens registry PR #n). It then marks #n with a note headed `## <work skill> · Train`, or `## <work skill> · Train · urgent: <why>` to ask for a train now. It waits on #n's merge as before: parked, with its slot free.
|
|
419
508
|
2. Every 15 minutes, the first a minute after the start, the daemon reads the repo's open PRs and their comments. While the marked ones not yet on a train reach `minimum`, one is urgent, or the oldest has waited `maxHours`, one `operator` inbox item asks for `conduct train <project>`. While a train PR is open, a `merge` item asks you to merge it. Each resolves itself once it no longer holds.
|
|
420
509
|
3. `conduct train` takes each marked PR that targets the default branch, has passed its checks, and changes no file another member changes. It creates `train/<yyyymmdd-hhmm>` (UTC) from the default branch and merges each member's head into it with GitHub's merges API, so nothing is checked out. It then opens one PR, which lists the members as `- #n …` lines, and notes `<work skill> · Train opened · merge: <url>` on each member. A merge GitHub refuses deletes the branch, and nothing is opened. `--dry-run` only reads. It lists what waits, what a train would take, and why the rest stay out.
|
|
421
510
|
4. Merge the train with a merge commit. Each member's head is then on the default branch, so GitHub shows the member merged and its worker wakes. A member pushed to after the train opened stays open, and rides the next train.
|
|
@@ -428,20 +517,20 @@ A worker waits by posting its note, running `conduct wait …` and ending the tu
|
|
|
428
517
|
|
|
429
518
|
| Verb | Full form | Holds when |
|
|
430
519
|
|---|---|---|
|
|
431
|
-
| `pr 1850` | `pr:ado:
|
|
432
|
-
| `pr 1850 merged` | `pr:ado:
|
|
520
|
+
| `pr 1850` | `pr:ado:Contoso.Web#1850:verdict` | the PR's verdict isn't `waiting` (see Worker verbs); the daemon keeps a recorded landing armed meanwhile |
|
|
521
|
+
| `pr 1850 merged` | `pr:ado:Contoso.Web#1850:merged` | the PR is completed (or abandoned: that resolves it too, as a fact) |
|
|
433
522
|
| `pr 1850 checks` | `…#1850:checks` | no blocking build of the PR's current commit is queued or running (passed or failed; a path-filtered build it doesn't touch counts as passed) |
|
|
434
523
|
| `pr 1850 comment` | `…#1850:comment` | a thread is open whose last word isn't a work-skill note |
|
|
435
524
|
| `pr 458 merged --repo <owner>/<repo>` | `pr:github:<owner>/<repo>#458:merged` | the same on GitHub; `comment` there is a person's comment or review after the wait was registered. Without `--repo`, a project whose `codeHost` is `{ "kind": "github", "owner", "repo" }` means its own repo |
|
|
436
525
|
| `pr 1850 merged checks comment` | one wait per event | registers the three in one call; the first that holds wakes the worker. `verdict` can't be combined with other events |
|
|
437
|
-
| `merge dda5b26c` / `--for deploy-api` | `merge:ado:
|
|
526
|
+
| `merge dda5b26c` / `--for deploy-api` | `merge:ado:Contoso.Web@dda5b26c:for=deploy-api` | the merge's runs settled as `conduct runs` reads them (its verdict isn't `waiting`). The wake names the outcome, the failed pipelines and each release's staged version with its approve line. The ADO watcher reads the merge's git in the worker's worktree, once per wait |
|
|
438
527
|
| `merge 5426434c --repo <owner>/<repo>` | `merge:github:<owner>/<repo>@5426434c` | the same on GitHub Actions: the runs the merge's push started settled |
|
|
439
|
-
| `run
|
|
440
|
-
| `npm 8.5.0-dev.2 --tag dev` | `npm:@
|
|
528
|
+
| `run web-api-deploy 12345 done` | `run:ado:web-api-deploy#12345:done` | the run completed, with its result |
|
|
529
|
+
| `npm 8.5.0-dev.2 --tag dev` | `npm:@contoso/cli@8.5.0-dev.2:dev` | the dist-tag points at the version, read through the `npm-stage` release adapter |
|
|
441
530
|
| `health dev --version 1.2.3` / `--commit <sha>` | `health:dev:version=1.2.3` | the profile's `health.dev` URL answers with that version or commit |
|
|
442
|
-
| `approval` | `approval:linear:
|
|
443
|
-
| `item
|
|
444
|
-
| `login ado` / `github` / `linear` | `login:ado:
|
|
531
|
+
| `approval` | `approval:linear:ENG-312` (`approval:ado-boards:CAD-12` on Boards, `approval:github-issues:PAR-12` on GitHub Issues) | an `approve` / `revise:` comment by an approver after the item's newest `<work skill> · Plan` note: the Linear key's owner (on Boards, the `az login` identity; on GitHub Issues, the gh account), or an account the profile's `approvers` names by email (Linear), unique name (Boards: the UPN, usually the email) or login (GitHub). An answer by anyone else is ignored, and the plan's inbox item says whose it was (`approval.ignored`) |
|
|
532
|
+
| `item ENG-312 done` / `label human` / `note Landed` | `item:linear:ENG-312:state=done` | the state (name or type), a label (a tag on Boards), or a phase note; on GitHub Issues the state is `open`, `closed`, a phase label or its type |
|
|
533
|
+
| `login ado` / `github` / `linear` | `login:ado:web` | the project's login to the provider works again: a probe as its account (see Accounts) succeeds |
|
|
445
534
|
| `time 2026-10-10T09:00` | `time:2026-10-10T14:00:00.000Z` | the time has passed (no zone means local time) |
|
|
446
535
|
|
|
447
536
|
- **With the daemon**, in a worker's worktree (or with `--worker <worker>`), `conduct wait` registers the wait and returns at once. The daemon parks the session when its turn ends and resumes it with `[conductor] event: <resolution> HH:MM` once the condition holds. The text is a fact, never an approval: the worker reads the source again. The daemon checks waits against the project's stored profile (the last good one read from disk), and refuses one it can't check, such as a `health` environment the profile has no URL for. A condition the worker already waits on (not resolved yet) is that wait, so registering every condition again after a wake adds no duplicates; `wait.added` says `existing: true` then.
|
|
@@ -459,8 +548,8 @@ Accounts belong to the person running the daemon, not to the repo, so each proje
|
|
|
459
548
|
|
|
460
549
|
```jsonc
|
|
461
550
|
"projects": {
|
|
462
|
-
"
|
|
463
|
-
"
|
|
551
|
+
"web": { "ado": { "token": "conductor-web-ado" }, "linear": { "credential": "conductor-web-linear" } },
|
|
552
|
+
"portal": { "ado": { "tenant": "<tenant id or domain>" }, "github": { "user": "<gh account>" } }
|
|
464
553
|
}
|
|
465
554
|
```
|
|
466
555
|
|
|
@@ -468,20 +557,32 @@ Accounts belong to the person running the daemon, not to the repo, so each proje
|
|
|
468
557
|
|
|
469
558
|
| Order | Source | Azure DevOps (PRs, runs, Boards) | GitHub (reads, and writes such as `conduct train`'s) | Linear (waits, notes, labels, queue, briefs) |
|
|
470
559
|
|---|---|---|---|---|
|
|
471
|
-
| 1 | **project token**: a
|
|
472
|
-
| 2 | **machine token**: a conductor-only env var, else a
|
|
560
|
+
| 1 | **project token**: a credential in the machine's secret store that the project names | `projects.<name>.ado.token`: a PAT, sent as Basic auth | `projects.<name>.github.token`: a token, as `GH_TOKEN` | `projects.<name>.linear.credential`: an API key |
|
|
561
|
+
| 2 | **machine token**: a conductor-only env var, else a credential in the store | `CONDUCTOR_ADO_TOKEN`, else `conductor-ado` | `CONDUCTOR_GITHUB_TOKEN`, else `conductor-github` | `CONDUCTOR_LINEAR_KEY` or `LINEAR_API_KEY`, else `conductor-linear` (`CONDUCTOR_LINEAR_CREDENTIAL` names another) |
|
|
473
562
|
| 3 | **CLI login**, last: it usually carries the widest permissions | `az account get-access-token --resource 499b84ac-…`, with `--tenant <tenant>` when `ado.tenant` names one | `gh auth token`, with `--user <user>` when `github.user` names one, so gh's active account doesn't change | none |
|
|
474
563
|
|
|
475
|
-
|
|
564
|
+
**The secret store** is the platform's own:
|
|
565
|
+
|
|
566
|
+
| Platform | Store | Save a token (each asks for the secret) |
|
|
567
|
+
|---|---|---|
|
|
568
|
+
| Windows | Credential Manager (generic credentials) | `cmdkey /generic:<target> /user:pat /pass` |
|
|
569
|
+
| macOS | the login keychain (generic passwords, the target as service) | `security delete-generic-password -s <target>; security add-generic-password -s <target> -a pat -w` |
|
|
570
|
+
| Linux | libsecret (the Secret Service: GNOME Keyring, KeePassXC …), attributes `service=conductor target=<target>` | `secret-tool clear service conductor target <target>; secret-tool store --label=<target> service conductor target <target> user pat` |
|
|
571
|
+
|
|
572
|
+
Linear's key is saved with the user name `linear` instead of `pat`. On macOS and Linux the old item is deleted first: an item there is kept per user name (macOS) or per set of attributes (Linux), so saving under another user name would leave two. `conduct project check` prints the line for its platform. **Env fallback:** where the store has no such credential, or can't be read (`secret-tool` missing, or a headless Linux box with no D-Bus session or keyring), the conductor reads `CONDUCTOR_SECRET_<TARGET>` instead: the target in upper snake case, so `conductor-web-ado` is `CONDUCTOR_SECRET_CONDUCTOR_WEB_ADO`, with its user name, when one is needed (Pushover), in the same name plus `_USER`. Targets that differ only in punctuation (`a.b`, `a-b`) share a variable, so give them names that differ in letters or digits.
|
|
573
|
+
|
|
574
|
+
**The names lost their ATK prefix in ATK-529:** the machine's Linear key is `conductor-linear` (it was `atk-conductor-linear`), Pushover's `conductor-pushover` (it was `atk-conductor-pushover`), and `CONDUCTOR_LINEAR_CREDENTIAL` replaces `ATK_CONDUCT_LINEAR_CREDENTIAL`, which is no longer read. On Windows, the first read of a new name that finds none copies the old Credential Manager entry to it, with its user name and secret, and leaves the old one in place for an older conductor; after that the new one is found, so the old name is never read again. Delete the old ones with `cmdkey /delete:atk-conductor-linear` and `cmdkey /delete:atk-conductor-pushover` once no older conductor runs.
|
|
575
|
+
|
|
576
|
+
Only the conductor's own env names count: another tool's `AZURE_DEVOPS_PAT` or `GITHUB_TOKEN` never changes what the conductor signs in as. A token scoped to one project wants:
|
|
476
577
|
- **Azure DevOps:** Code (read & write) and Build (read & execute), plus Work Items (read & write) for Boards;
|
|
477
578
|
- **GitHub:** a fine-grained token on the repo with Contents, Pull requests and Issues (read & write) and Actions (read);
|
|
478
579
|
- **Linear:** an API key limited to the project's team.
|
|
479
580
|
|
|
480
581
|
`conduct project check <name>` says which source each provider signed in with, and for each that isn't the project's own token, the line that would scope it (its `auth` check).
|
|
481
582
|
|
|
482
|
-
A project whose entry names its CLI account (`ado.tenant`, `github.user`) keeps that account ahead of the machine's token, which is another identity and may not reach the project's organization. One machine ADO PAT serves every other project, so a project in another organization than the PAT's gets a token of its own.
|
|
583
|
+
A project whose entry names its CLI account (`ado.tenant`, `github.user`) keeps that account ahead of the machine's token, which is another identity and may not reach the project's organization. One machine ADO PAT serves every other project, so a project in another organization than the PAT's gets a token of its own. Until it does, the machine PAT's lost login is kept to the organization that refused it: that project's calls wait on its own `login` item, and the projects in the PAT's organization keep working.
|
|
483
584
|
|
|
484
|
-
Found means there: a project token that is named but missing, or any token that is there but refused, is a lost login (below), never a quiet fall back to a wider credential. A refused PAT is read again once, so a replaced one is tried; the same one refused again is the lost login. The machine credential is optional: one that
|
|
585
|
+
Found means there: a project token that is named but missing, or any token that is there but refused, is a lost login (below), never a quiet fall back to a wider credential. A refused PAT is read again once, so a replaced one is tried; the same one refused again is the lost login. The machine credential is optional: one that the store says isn't saved (on Windows `cmdkey /list`, so no PowerShell starts), or that can't be read, is passed over.
|
|
485
586
|
|
|
486
587
|
Each credential is kept per account: an az token 20 minutes, renewed when ADO answers 401 or 203; a token read again every 20 minutes, so a replaced or removed one is seen; a gh token asked again once on a 401; a Linear key until Linear answers 401, when it is read again once. Projects on the same account share it. The secrets are never logged, stored or put on a command line.
|
|
487
588
|
|
|
@@ -491,11 +592,39 @@ Each credential is kept per account: an az token 20 minutes, renewed when ADO an
|
|
|
491
592
|
2. While the spell lasts, the account's calls fail at once, asking nothing. Every `watchers.login.intervalSeconds` (60) the daemon probes each lost account again: a fresh az token for ADO (a PAT is asked `_apis/connectionData` on the project's organization, since az can't vouch for it), the token and `gh api user` for GitHub, and the key and `{ viewer { id } }` for Linear. That is the spell's back-off.
|
|
492
593
|
3. The first probe the provider answers (an answer other than a refusal; a timeout, a network error or a 5xx says nothing) logs `provider.login-restored`, which resolves the item, and calls go through again.
|
|
493
594
|
|
|
494
|
-
`conduct wait login <provider>` waits on the same probe for the worktree's project (`login:<provider>:<project>`), within the `login` watcher's budget. A worker that can't go on without a login posts its note, waits on it and ends its turn; once
|
|
595
|
+
`conduct wait login <provider>` waits on the same probe for the worktree's project (`login:<provider>:<project>`), within the `login` watcher's budget. A worker that can't go on without a login posts its note, waits on it and ends its turn; once the operator signs in, the daemon wakes it. **Exit code 5** ("login needed") is what the worker verbs (`state`, `pr`, `land`, `reply`) exit with when they hit a lost login, here or in the daemon.
|
|
596
|
+
|
|
597
|
+
## Linear
|
|
598
|
+
|
|
599
|
+
A project whose items are a Linear team's issues sets `tracker.kind` to `linear`. Only the team is required; the rest are defaults written as data (ATK-530). A conductor before 3.0.0-dev.32 requires `areaLabelGroup`, `humanLabel` and `queue` and refuses a null `areaLabelGroup`, and the minimum check (`FIELD_SINCE`) sees only fields a profile writes, not ones it leaves out or sets to null. So keep them written, as `conduct init` does, until every daemon and `conduct` that reads the profile is on 3.0.0-dev.32 or newer.
|
|
600
|
+
|
|
601
|
+
```jsonc
|
|
602
|
+
"tracker": {
|
|
603
|
+
"kind": "linear",
|
|
604
|
+
"team": "ENG", // items are ENG-<n>
|
|
605
|
+
"queue": "prioritized-projects", // the default: the team's projects with a priority are the queue
|
|
606
|
+
// "queue": { "label": "conduct", "priority": 3 }: the team's open issues carrying the label, in one queue
|
|
607
|
+
"areaLabelGroup": "Area", // the default: the label group whose labels are the Areas; null for none
|
|
608
|
+
"humanLabel": "human", // the default
|
|
609
|
+
"triageLabel": "triage", // optional (see Scheduler)
|
|
610
|
+
"transitions": { "landed": "Shipped" } // optional, over the state names in Notes
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
| Tracker concept | On Linear |
|
|
615
|
+
|---|---|
|
|
616
|
+
| the queue | `prioritized-projects`: the open items of the team's projects that have a priority (1 urgent … 4 low), by that priority, then the projects' order. A label queue (`{ "label", "priority" }`, priority 3 by default; conductor 3.0.0-dev.32 or newer) is one tracker project named `<team> · <label>` at that priority, holding the open items that carry the label (in any case) and the in-flight ones whose label was taken off. As on GitHub Issues, it is ready to release whenever no open item carries the label. Either way every open item of the team is read, so one outside the queue still blocks and holds its Area. Within a priority the scheduler keeps the items' `prioritySortOrder`, the order a view ordered by priority shows, one value per item for the whole workspace (ATK-666). Linear puts a new item at the top of its priority, so items nobody dragged start newest first. |
|
|
617
|
+
| Area | the item's label in the `areaLabelGroup` group. With `null` the team has no Areas: no item holds one, as a Boards item at its root, and `conduct file` takes no `--area` (one given is dropped, with a warning). |
|
|
618
|
+
| blockers | "blocks" relations whose item is one of the team's open items (another team's open item holds only a release wait) |
|
|
619
|
+
| human, triage, labels | labels |
|
|
620
|
+
| states | read by Linear's status type (backlog, unstarted, started, completed, canceled), so a team's own state names need nothing; the writes move to the `transitions` names (Notes), which `tracker.transitions` overrides |
|
|
621
|
+
| phase notes, `approve` / `revise:` | comments, as the key's owner |
|
|
622
|
+
|
|
623
|
+
The tracker seams (the brief, labels, notes, the queue and the writes) are one `Tracker` per kind in `src/trackers.ts`, and `routedTracker` sends each call to the adapter of its project's tracker kind.
|
|
495
624
|
|
|
496
625
|
## ADO Boards
|
|
497
626
|
|
|
498
|
-
A project whose work items live in Azure DevOps Boards
|
|
627
|
+
A project whose work items live in Azure DevOps Boards sets `tracker.kind` to `ado-boards` (design note, section 12; ATK-319):
|
|
499
628
|
|
|
500
629
|
```jsonc
|
|
501
630
|
"tracker": {
|
|
@@ -503,10 +632,10 @@ A project whose work items live in Azure DevOps Boards (Cadence) sets `tracker.k
|
|
|
503
632
|
"key": "CAD", // items are CAD-<work item id>: ADO's ids are numbers alone
|
|
504
633
|
"query": "Shared Queries/Conduct", // the saved query that is the queue, by path or id
|
|
505
634
|
"humanLabel": "human", // a tag here
|
|
506
|
-
"triageLabel": "triage", // optional, a tag here too: new items
|
|
635
|
+
"triageLabel": "triage", // optional, a tag here too: new items the operator hasn't triaged (see Scheduler)
|
|
507
636
|
"priority": 3, // the queue's priority against other projects' (1 urgent … 4 low)
|
|
508
|
-
"areaRoot": "
|
|
509
|
-
"states": { "Blocked": "started" }
|
|
637
|
+
"areaRoot": "Contoso\\Portal", // optional: an item's Area is its Area Path below it
|
|
638
|
+
"states": { "Blocked": "started" } // optional, over the built-in state names
|
|
510
639
|
// "organization", "project": the Azure DevOps code host's by default; required beside a GitHub one
|
|
511
640
|
}
|
|
512
641
|
```
|
|
@@ -531,7 +660,7 @@ A project whose items are a GitHub repo's issues sets `tracker.kind` to `github-
|
|
|
531
660
|
"kind": "github-issues",
|
|
532
661
|
"key": "PAR", // items are PAR-<issue number>: issue numbers are numbers alone
|
|
533
662
|
"humanLabel": "human", // a label
|
|
534
|
-
"triageLabel": "triage", // optional, a label too: new items
|
|
663
|
+
"triageLabel": "triage", // optional, a label too: new items the operator hasn't triaged (see Scheduler)
|
|
535
664
|
"queueLabel": "conduct", // the default: the open issues carrying it are the queue
|
|
536
665
|
"areaPrefix": "area:", // the default: area:docs is the Area docs
|
|
537
666
|
"priority": 3, // the queue's priority against other projects' (1 urgent … 4 low)
|
|
@@ -547,29 +676,29 @@ A project whose items are a GitHub repo's issues sets `tracker.kind` to `github-
|
|
|
547
676
|
| blockers | GitHub's issue dependencies (`blocked by`) still open, inside the queue or not; one in another repo is listed as `<owner>/<repo>#<n>` |
|
|
548
677
|
| human, triage, labels | labels, matched in any case |
|
|
549
678
|
| states | open → unstarted; open with a `transitions` label → started (named after the label); closed as completed → completed; closed as not planned or a duplicate → canceled |
|
|
550
|
-
| phase notes, `approve` / `revise:` | issue comments; the gh account's own comments count as
|
|
679
|
+
| phase notes, `approve` / `revise:` | issue comments; the gh account's own comments count as the operator's, so the note heading tells them apart |
|
|
551
680
|
|
|
552
681
|
`Closes #n` would close an issue only on a merge to the default branch, so the conductor closes it on the `Landed` note instead, and a project on a `develop` branch works the same. Phase labels are added and taken off one at a time, never replaced as a set, so a label someone else added meanwhile stays. GitHub makes a label it doesn't have yet when a phase or rule first adds it; `conduct file` refuses an Area with no label.
|
|
553
682
|
|
|
554
|
-
Everything goes through the project's gh account (`projects.<name>.github.user`, else gh's active account; see Accounts). The reads (the scheduler's queue and the watchers' waits) take from the `github` watcher's budget, as PR and merge waits on GitHub do. A wait pass reads each waited issue once, and its comments only when its update time or comment count moved. Notes, label edits and the tracker writes are outside the budget, as on Linear. That account's rate limit is shared with anything else it runs
|
|
683
|
+
Everything goes through the project's gh account (`projects.<name>.github.user`, else gh's active account; see Accounts). The reads (the scheduler's queue and the watchers' waits) take from the `github` watcher's budget, as PR and merge waits on GitHub do. A wait pass reads each waited issue once, and its comments only when its update time or comment count moved. Notes, label edits and the tracker writes are outside the budget, as on Linear. That account's rate limit is shared with anything else it runs, so the `github` watcher's `perMinute` bounds it.
|
|
555
684
|
|
|
556
685
|
## Broker
|
|
557
686
|
|
|
558
687
|
The broker shares what every project on the machine uses (ATK-318).
|
|
559
688
|
|
|
560
|
-
**Ports.** Each profile names the ports a worktree's dev servers need, such as
|
|
689
|
+
**Ports.** Each profile names the ports a worktree's dev servers need, such as `"ports": ["web", "api"]` (design note v2, section 2; ATK-526).
|
|
561
690
|
- A launch gives the worktree one port per name from the machine's pool, `ports.first` to `ports.last` in config.json (41000 to 41999): each the pool's first port that no other worktree of any project holds, nothing listens on (IPv4 or IPv6 loopback), and that binds, which also skips a range Windows reserves. They reach `setup` and the worker as `CONDUCTOR_PORT_<NAME>` (see Workers).
|
|
562
|
-
- The pool sits away from the usual dev ports and their slot schemes, such as
|
|
691
|
+
- The pool sits away from the usual dev ports and their slot schemes, such as 3000+N for a person's own worktrees, so those never take a port a worker was given.
|
|
563
692
|
- A worktree keeps its ports while the profile names the same ports; a changed list gives it new ones. Retiring the worker frees them, and so does a worktree whose directory is gone (at the next assignment).
|
|
564
693
|
- `conduct ports` lists the assignments, and `conduct ports <path>` gives one worktree's (`--json` for either). Only a launch assigns. `conduct project check` previews the ports a new worktree would get, saving nothing.
|
|
565
694
|
|
|
566
|
-
**Locks.** `conduct lock acquire chrome` takes the lock every project's verify runs share. It answers
|
|
567
|
-
- The owner is `--owner`, else the item of the worker whose worktree this is.
|
|
695
|
+
**Locks.** `conduct lock acquire chrome` takes the lock every project's verify runs share. It answers JSON: `acquired`, `heldBy`, `since`, `refreshed`; `released` for a release; `verdict` for a status.
|
|
696
|
+
- The owner is `--owner`, else the item of the worker whose worktree this is, else the item key in the worktree's name or branch, so a session the conductor didn't start takes turns with its workers (ATK-668). With none of them it exits 1.
|
|
568
697
|
- Acquiring again refreshes it. 30 minutes after its holder last acquired it, another owner may take it over.
|
|
569
698
|
- `--wait <minutes>` asks again every 15 s until then.
|
|
570
699
|
- Retiring a worker releases the locks its item holds (a probe's retire doesn't).
|
|
571
700
|
- `conduct status` lists the locks held, each with its holder, its last acquire and whether it is stale.
|
|
572
|
-
- With no daemon it takes a file lock every worktree of the checkout's repo shares; outside a project checkout it exits 1 (`not brokered`).
|
|
701
|
+
- With no daemon it takes a file lock every worktree of the checkout's repo shares; outside a project checkout it exits 1 (`not brokered`). On Windows a refresh can't replace a lock file something holds open (a virus scan, another worktree's read); after a second it answers `refreshed: false` with the lock still the owner's, going stale 30 minutes after `since` (ATK-652).
|
|
573
702
|
|
|
574
703
|
Events: `ports.assigned`, `ports.freed`, `lock.acquired`, `lock.released`, `lock.taken-over`.
|
|
575
704
|
|
|
@@ -609,7 +738,8 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
|
609
738
|
|
|
610
739
|
| File | Holds |
|
|
611
740
|
|---|---|
|
|
612
|
-
| `
|
|
741
|
+
| `projects/` | each conducted project's machine profile, clone and worktrees (see Projects) |
|
|
742
|
+
| `config.json` | machine settings, JSONC (comments allowed), validated at startup; `clones.fetchMinutes` (10, see Projects); `inbox.runawayTokens` (1500000, the `runaway` rule's number); `notify` and `quietHours` (see Notifications); `scheduler` holds `mode` (`on` or `off`), `slots` (4, across projects, and the most any rule can give one project), `dailyTokens` (20000000, the `daily-cap` rule's number) and `tickSeconds` (120); `sessions` holds `pollSeconds` (10), `settleSeconds` (5), `permissionMinutes` (5), `idleMinutes` (20), `compactTokens` (300000, the `compact-on-park` rule's number); `watchers` holds `ado`, `github`, `health`, `linear` and `npm`, each `{ intervalSeconds, perMinute }` (30/60, 30/30, 30/30, 30/10, 20/30), `statsMinutes` (10) and `conflictMinutes` (30); `workers` holds `tab` (false: workers run headless) and `terminal` (`auto`; see Tabs) |
|
|
613
743
|
| `rules.json` | machine rules, JSONC, reloaded on save (see Rules) |
|
|
614
744
|
| `token` | the API token, readable by this user only; every request sends `Authorization: Bearer <token>` |
|
|
615
745
|
| `conductor.pid`, `port` | the single-instance lock and where the API listens (`127.0.0.1`) |
|
|
@@ -618,7 +748,7 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
|
618
748
|
|
|
619
749
|
## API
|
|
620
750
|
|
|
621
|
-
- `GET /projects`: the registered projects
|
|
751
|
+
- `GET /projects`: the registered projects, each with its `clone`, `url`, profile and `sources`. `POST /projects` with `{ "name": "<name>" }` registers the clone `conduct project add` made at `~/.conductor/projects/<name>/repo` (400 when there is none, or its profile doesn't read). `DELETE /projects/<name>` removes one: `{ project, resolved }` (the inbox items it resolved), 404 for a name no project is registered under, or 409 naming its workers in flight. `POST /projects/<name>/check` with `{ "worktree": false }` or `{}` runs `conduct project check` as the daemon's accounts: `{ project, results: [{ check, status, summary, details, fix }] }`, or 404 for a name no project is registered under.
|
|
622
752
|
- `GET /events`: the event log as server-sent events. Add `?since=<id>` (or send `Last-Event-ID`) to replay from an id. Without either, the stream starts at the live end. `conduct events` prints it one JSON event per line: the events after `--since` (all of them without it), or with `--follow` everything from there on (live without `--since`). `--follow` reconnects with `Last-Event-ID` when the daemon goes away, so a restart loses nothing, and prints the stream's state as lines without an id: `{"kind":"conduct.connected"}` and, once per outage, `{"kind":"conduct.disconnected","error":…}`. `?console=1` (`conduct events --follow --console`, which the conductor mod runs, or `conduct events --follow` with `CONDUCTOR_CONSOLE=1`, as a mod from before `--console` runs it until `/reload-plugins`) marks the console's stream: while one is open, escalations have a console to wake (see Console).
|
|
623
753
|
- `POST /shutdown`: a clean stop (`conduct stop`).
|
|
624
754
|
- `GET /console/escalations`: the open escalations, oldest first (see Console).
|
|
@@ -626,15 +756,16 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
|
626
756
|
- `POST /console/cache` with `{ "action": "warm" | "cold" | "failed", "contextTokens", "usage": { "cacheRead", "cacheWrite", "input", "output" } | null, "warms" }` or `{ "action": "clear", "contextTokens", "reason" }`: the mod's report on the idle console's prompt cache (see Mod); 201 with the `console.cache` event's id.
|
|
627
757
|
- `POST /plugin/seen` with `{ "sessionId", "version"? }`: the mod's report that the conductor plugin loaded in a session (see Plugin); 200 with `{ "worker": <name> | null }`, the worker that session belongs to.
|
|
628
758
|
- `GET /fleet`: the workers in flight, with session activity, phase, PR, waits and spend (today, total).
|
|
629
|
-
- `POST /workers` with `{ "item": "
|
|
759
|
+
- `POST /workers` with `{ "item": "ENG-312", "project"?, "probe"?, "prompt"?, "conductor"? }`: launch; answers 202 with the `launching` worker. `GET /workers/:id` shows it with its latest events.
|
|
630
760
|
- `DELETE /workers/:id` (`?force=true`): retire; 409 with the unfinished work (the worktree's, and each `.scratch/repos` checkout's under `checkouts`) when it refuses.
|
|
631
|
-
- `POST /workers/:id/attach`: wake if parked, mark attached, open the tab.
|
|
761
|
+
- `POST /workers/:id/attach`: wake if parked, mark attached, open the tab; `tab` is why none opened, else null, `hint` how to reach one off the desktop (`tmux attach -t <session>`), and `already` true when a `claude attach` client runs for the session, so no tab opened.
|
|
632
762
|
- `POST /workers/:id/prs` with `{ "title", "body", "repo"? }`: open the worker's PR after the ownership checks (see Worker verbs); 201 with the PR, 409 refused, 503 a lost login (with `login.provider` and `login.command`), 502 the host failed.
|
|
633
|
-
- `POST /workers/:id/landings` with `{ "pr", "reason", "mode"?, "rearm"?, "repo"? }`: `conduct land` for the worker; 200 with the landing and its verdict. `POST /workers/:id/replies` with `{ "pr", "body", "thread"?, "repo"? }`: `conduct reply`; 201. Both answer 409, 503 and 502 as `/prs` does.
|
|
634
|
-
- `POST /items/:id/notes` with `{ "body": "## work-
|
|
763
|
+
- `POST /workers/:id/landings` with `{ "pr", "reason", "mode"?, "rearm"?, "repo"?, "requeue"? }`: `conduct land` for the worker; 200 with the landing and its verdict. `POST /workers/:id/replies` with `{ "pr", "body", "thread"?, "repo"? }`: `conduct reply`; 201. Both answer 409, 503 and 502 as `/prs` does.
|
|
764
|
+
- `POST /items/:id/notes` with `{ "body": "## work-item · …", "worker"?: 1 }`: post a phase note (see Notes); 201 with the note and the worker. 409 when it won't post it; 502 when the tracker didn't take it, with `posted: "no"` or `"unknown"`.
|
|
635
765
|
- `GET /items/:id/brief` (`?fresh=true`): the item's brief (see Briefs), as `{ "brief": … }`. 404 when the tracker has no such item, 409 when no project tracks it, 502 when the tracker couldn't be read.
|
|
766
|
+
- `POST /items/:id/revise` with `{ "text" }`: `conduct revise` on an approved plan (see Inbox); 200 with `{ "revise": { comment, item, project, revised, woken, landed, worker, pending } }`. 409 when the item has no approved Plan (nothing posted); 502 when the tracker didn't take the comment, with `posted`.
|
|
636
767
|
- `GET /inbox`: the open inbox items, oldest first.
|
|
637
|
-
- `POST /inbox/:id/resolve`:
|
|
768
|
+
- `POST /inbox/:id/resolve`: the operator's answer (see Inbox). The body is `{ "action": "approve", "text"? }`, `{ "action": "revise", "text" }` or `{ "action": "done" }`.
|
|
638
769
|
- 200 with the item, the comment and the waits it resolved.
|
|
639
770
|
- 404 when there is no such item.
|
|
640
771
|
- 409 when the item is resolved already or isn't a plan.
|
|
@@ -647,47 +778,57 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
|
647
778
|
- `GET /queue`: the scheduler's last plan; `?fresh=true` reads the trackers and plans now (see Scheduler).
|
|
648
779
|
- `GET /overrides`, and `POST /overrides` with `{ "action": "pause", "project"?, "until"? }`, `{ "action": "unpause", "project"? }` or `{ "action": "focus", "project", "slots"?, "weight"?, "until" }` (and `by`, who set it): 201 with the overrides; 409 for an unknown project, a time past, or a focus over `scheduler.slots`.
|
|
649
780
|
- `GET /history?since=<ISO time>&until=<ISO time>&project=<name>`: the report for that window (see History). Every parameter is optional; the default window is the 24 hours before `until` (default: now). 400 for a time it can't read, a window whose start isn't before its end, or an unknown parameter.
|
|
650
|
-
- `GET /why/:id`: the event's chain of causes and the rule behind it; `GET /why?item=
|
|
651
|
-
- `POST /rules/test` with `{ "event"?: "pr.comment", "project"?: "
|
|
781
|
+
- `GET /why/:id`: the event's chain of causes and the rule behind it; `GET /why?item=ENG-312` the same for the item's newest actions.
|
|
782
|
+
- `POST /rules/test` with `{ "event"?: "pr.comment", "project"?: "web", "fields"?: { … } }`: a dry run; 400 for a sample it can't read.
|
|
652
783
|
- `GET /ports`: every worktree's port assignment. `POST /ports` with `{ "worktree": "<absolute path>", "prefer"?: 3 }`: the checkout's (see Broker); 200 with `assignment`, 409 when the path is in no registered project or no slot is free.
|
|
653
|
-
- `GET /locks`: the locks held. `POST /locks/:name` with `{ "action": "acquire" | "release" | "status", "owner"? }`: 200 with the
|
|
654
|
-
- `POST /waits` with `{ "worker": 1, "condition": "pr:ado:
|
|
784
|
+
- `GET /locks`: the locks held. `POST /locks/:name` with `{ "action": "acquire" | "release" | "status", "owner"? }`: 200 with the lock's JSON (see Broker); 409 for an acquire or release without an owner.
|
|
785
|
+
- `POST /waits` with `{ "worker": 1, "condition": "pr:ado:Contoso.Web#1850:merged" }`: a wait in its full form (see Waits), stored in its normal form; 201 with the wait. `GET /waits/:id` shows one, with its resolution once it has one.
|
|
655
786
|
- A refusal (a duplicate launch, a bad condition, a busy worker) is a 409 with `error` and `details`.
|
|
656
787
|
|
|
657
788
|
## Restarts
|
|
658
789
|
|
|
659
|
-
`conduct supervise` runs `conduct serve` as its child and starts it again when it dies: a crash, a kill, a start that fails. The logon
|
|
790
|
+
`conduct supervise` runs `conduct serve` as its child and starts it again when it dies: a crash, a kill, a start that fails. The logon startup (see On PATH) runs it, and `conduct start` and `conduct upgrade` start it detached.
|
|
660
791
|
|
|
661
792
|
- **Restart:** after 1 s, 5 s, 15 s and 30 s, then every minute, and every 5 minutes once it has pushed. A serve that stayed up 5 minutes ends the outage, so the next death starts at 1 s again. Each restarted daemon records `daemon.restarted` (`attempt`, the last serve's `code` or `signal`, `downSince`, `pushed`) after the `daemon.exited-uncleanly` its start finds.
|
|
662
793
|
- **Push:** a daemon down 2 minutes sends one Pushover push (`conductor · the daemon is down`), with the last serve's stderr and the log's path. It uses `notify.pushover` from `config.json` and goes to Pushover whatever the notification profile: a `conduct notify profile` lives in the daemon's store, which may be what keeps it down. Quiet hours hold it until they end, if the daemon is still down. A `config.json` that doesn't load leaves the defaults, because a bad config is one way a daemon stays down. It tries once per outage; a refusal is logged.
|
|
663
|
-
- **Stop:** a serve that exits 0 (`conduct stop`, `conduct upgrade`) ends the supervisor. While it waits between restarts nothing answers the API, so `conduct stop` and `conduct upgrade` write a request to `supervisor.stop` in the conductor home when no daemon answers (ATK-433). The supervisor checks for one every 250 ms while it waits, and after each serve exits. It ends, writes its pid into the request as its answer, and `stop` prints `stopped the conductor's supervisor (pid N), which was waiting to restart the daemon`. `stop` waits 10 s for that answer, and a daemon that comes up meanwhile is shut down as usual. With no answer it says the conductor isn't running. However it ends, `stop` removes its request. A supervisor heeds only a request written after it started and in the last 30 s, and drops any other as it starts. So a request left behind by a logoff never stops the next logon's supervisor, and a stale one never ends a later outage. `stop` only asks when `supervisor.pid`, which a supervisor writes as it starts, names a live process. With nothing running it says so at once. The file is a hint, never a lock, and nothing ends the process it names: one left by a logoff that names a reused pid costs a `stop` its 10 s wait and nothing more. After the answer, `stop` waits for the supervisor's process to end, so a `conduct start` right after it isn't ignored by the logon task. Ctrl+C or Ctrl+Break on a `conduct supervise` in a terminal stops its daemon as `conduct stop` does (the serve runs in a console of its own), or cuts short a wait between restarts; press it again if the first came while the daemon was still starting. A serve that finds another daemon holding the lock (exit 3) ends it too. `--fail-fast`, which `conduct start` passes, also ends it when the first serve exits before it says it listens, so `start` fails at once and points at the log, which has the serve's stderr; one that came up and then died is restarted.
|
|
794
|
+
- **Stop:** a serve that exits 0 (`conduct stop`, `conduct upgrade`) ends the supervisor. While it waits between restarts nothing answers the API, so `conduct stop` and `conduct upgrade` write a request to `supervisor.stop` in the conductor home when no daemon answers (ATK-433). The supervisor checks for one every 250 ms while it waits, and after each serve exits. It ends, writes its pid into the request as its answer, and `stop` prints `stopped the conductor's supervisor (pid N), which was waiting to restart the daemon`. `stop` waits 10 s for that answer, and a daemon that comes up meanwhile is shut down as usual. With no answer it says the conductor isn't running. However it ends, `stop` removes its request. A supervisor heeds only a request written after it started and in the last 30 s, and drops any other as it starts. So a request left behind by a logoff never stops the next logon's supervisor, and a stale one never ends a later outage. `stop` only asks when `supervisor.pid`, which a supervisor writes as it starts, names a live process. With nothing running it says so at once. The file is a hint, never a lock, and nothing ends the process it names: one left by a logoff that names a reused pid costs a `stop` its 10 s wait and nothing more. After the answer, `stop` waits for the supervisor's process to end, so a `conduct start` right after it isn't ignored by the Windows logon task. Ctrl+C or Ctrl+Break on a `conduct supervise` in a terminal stops its daemon as `conduct stop` does (the serve runs in a console of its own), or cuts short a wait between restarts; press it again if the first came while the daemon was still starting. A serve that finds another daemon holding the lock (exit 3) ends it too. `--fail-fast`, which `conduct start` passes, also ends it when the first serve exits before it says it listens, so `start` fails at once and points at the log, which has the serve's stderr; one that came up and then died is restarted.
|
|
664
795
|
- **Logs:** the supervisor writes `the daemon exited; the supervisor restarts it` and its push results to `logs/conductor.log`.
|
|
665
|
-
- **Not covered:** a logoff or reboot ends the supervisor with the session, and the logon
|
|
796
|
+
- **Not covered:** a logoff or reboot ends the supervisor with the session (a systemd unit with lingering on outlives a logoff), and the logon startup starts it again at the next logon. A daemon that runs but doesn't answer isn't restarted.
|
|
666
797
|
|
|
667
798
|
## On PATH
|
|
668
799
|
|
|
669
|
-
Install the package globally
|
|
800
|
+
Install the package globally. `dev` follows `develop`; `latest` follows `main`:
|
|
670
801
|
|
|
671
802
|
```bash
|
|
672
803
|
npm install --global @detergent-software/conductor@dev
|
|
673
804
|
conduct install-startup
|
|
674
|
-
conduct project add <
|
|
805
|
+
conduct project add <the project's remote URL, or a checkout of it>
|
|
675
806
|
```
|
|
676
807
|
|
|
677
|
-
`conduct project add`
|
|
808
|
+
`conduct project add` clones the remote into `~/.conductor/projects/<name>/repo` (see Projects). After that the daemon reads the profile again when it starts, when the machine profile is saved and after each fetch of the clone, so a merge that changes `slots` or `skills.work` needs no `conduct project add`. An older daemon refuses a profile with fields it doesn't know, so upgrade the daemon before a profile that uses new ones lands.
|
|
678
809
|
|
|
679
810
|
A profile that uses new fields says which conductor reads them: `"conductor": "3.0.0-dev.9"`, the version that first read the newest of them (a `-dev.N` while it is only on `dev`; a stable minimum such as `3.0.0` is newer than every `3.0.0-dev.N`, as semver orders them). Every conductor from the one that knows this key (ATK-573) checks it before the rest of the profile, and an older one refuses the file with one line instead of a list of fields: `conductor: needs 3.0.0-dev.9 or newer; this is 3.0.0-dev.7: run \`conduct upgrade --tag dev\`` (`--tag latest` for a stable minimum). The worker verbs that read the profile in a worktree (`conduct state`, `conduct branch`, `conduct search`, a lock with no daemon) say the same and exit 1, and the daemon's inbox item carries the line and the upgrade command. While a project's profile on disk doesn't read (a minimum above this conductor, a schema problem, another protocol), the scheduler launches none of the project's items: each waits in `conduct queue` as `profile refused: <its first problem>`, since a new worktree would hold the same file and its verbs would refuse it. A profile that reads but whose rules are refused, or that renames the project, keeps the last good profile and launches as before. Workers already running carry on, and launches resume on the first tick after the file loads.
|
|
680
811
|
|
|
812
|
+
`src/profile-fields.ts` records the conductor that first read each field as it is written today (`FIELD_SINCE`, ATK-577), and its type makes a schema field without a record a typecheck error. Fields that 2.0.0 read, the first conductor that reads protocol 2, need no minimum: an older one refuses the profile on `protocol` first. `minimumProblems` reads a profile as written, so a field a default fills in doesn't count. It names each field the profile writes that is newer than its minimum, or newer than 2.0.0 when the profile sets none. A conductor test holds this repo's own `.claude/conductor.json` to it, and `atk-conductor-ci` runs on changes to that file. Any other project hears it from `conduct project check`, as a `warn` on its profile check.
|
|
813
|
+
|
|
814
|
+
A PR that adds or reshapes a field can't know the number semantic-release will give it, so it takes the next one:
|
|
815
|
+
|
|
816
|
+
1. Find the newest conductor tag: `git tag -l 'conductor-v*' --sort=-v:refname` (after a `git fetch --tags`). On `develop` the next release is its `-dev.N` plus one, such as `3.0.0-dev.12` after `3.0.0-dev.11`, unless the PR's commits make a bigger bump than that series: after a stable release, the first `-dev.1` of the version its commit type makes (a fix's patch, a feature's minor), and for a breaking change the next major's `-dev.1`, as `2.2.0-dev.3` was followed by `3.0.0-dev.1`.
|
|
817
|
+
2. Record that version for the field in `FIELD_SINCE`. If this repo's own profile writes the field in the same PR, raise its `conductor` to it too. Its launches are then held, with the upgrade line, until the daemon runs that release (`conduct upgrade --tag dev`, once a maintainer has approved its stage).
|
|
818
|
+
3. After the merge, `conduct runs <merge>` gives the version `atk-conductor-release` staged. If another conductor PR took the number first, the record and the minimum are one too low: fix both in a follow-up. Too low lets the version before the field read the profile, and fail with the field list this key replaces. Too high holds launches until that version exists.
|
|
819
|
+
|
|
820
|
+
A `test`, `chore` or `docs` change stages no release and records nothing.
|
|
821
|
+
|
|
681
822
|
After an `--allow-downgrade` to a version without those fields, the stored copy no longer validates either: the project shows `the stored profile no longer validates` and isn't conducted until its file, saved without them, is read again.
|
|
682
823
|
|
|
683
824
|
Then `conduct project check <name>` says whether the project can be conducted, before its first launch finds out. Each check prints one line, `ok`, `warn`, `fail` or `skip`, with a fix under a warn or a fail, and the command exits 1 when any check fails (`--json` prints the same as data):
|
|
684
825
|
|
|
685
|
-
- **profile:** the
|
|
686
|
-
- **auth:** which credential each provider the project uses signs in with (project token, machine token or CLI login; see Accounts), probed as the daemon would. For each that isn't the project's own token, a detail line gives the scopes, the `cmdkey`
|
|
826
|
+
- **profile:** the layers (see Projects) read with this build's schema; a `warn` lists the fields that differ from the daemon's stored copy, which it takes on the next save, fetch or start, and each field a file writes that is newer than its `conductor` minimum (`minimumProblems`, above), with the version to raise it to (ATK-580). A project still registered by a checkout fails, with the `conduct project add` that moves it to a clone. The later checks use the resolved profile when it reads, else the stored copy; with neither they are `skip`.
|
|
827
|
+
- **auth:** which credential each provider the project uses signs in with (project token, machine token or CLI login; see Accounts), probed as the daemon would. For each that isn't the project's own token, a detail line gives the scopes, the line that saves a token in the platform's store (`cmdkey`, `security` or `secret-tool`) and the config key that would scope it; that is advice, and the check passes. A token that is there but refused, or no credential at all, fails.
|
|
687
828
|
- **tracker:** the tracker's login answers, and the queue reads (Linear: the team, and whether any of its projects has a priority; Boards: the saved query; GitHub Issues: the labelled issues).
|
|
688
829
|
- **code-host** and **ci:** the code host's login answers and the base branch exists; each release's `pipeline` names exactly one of the repo's pipelines.
|
|
689
|
-
- **worktree:** the launch's worktree steps on a throwaway `conduct-check-<time
|
|
690
|
-
- **skills:** `
|
|
830
|
+
- **worktree:** the launch's worktree steps on a throwaway `conduct-check-<time>` in the project's worktrees folder: `git worktree add` in the clone from `origin/<base>`, `localFilesFrom`'s files, the ports the broker would give, and `setup`. Then the profile's skills must be there after it, and a `warn` says when git doesn't ignore `.claude/settings.local.json`, where the ports go. A `claude --bg` with no prompt then checks that Claude Code would start a session there: it starts idle, costs no tokens and is removed at once. Claude Code keeps workspace trust per repository, so a clone nobody has opened in Claude Code fails here with the one step that trusts it, `claude` run in the clone once with its trust prompt accepted (ATK-571). It is removed with its branch, whatever happened. The one check that writes; `--no-worktree` skips it, since setup can take a while (such as a `pnpm install`).
|
|
831
|
+
- **skills:** a `conductor:<skill>` is in this package's plugin, and `skills.work` declares `protocol: 2` in its SKILL.md frontmatter (worker protocol 2). A project's own skills arrive through its `setup` (a command that installs them), not in the clone, so the worktree check looks for them.
|
|
691
832
|
- **ports:** the machine's pool has a free port for each of the profile's `ports`, saving nothing; `skip` for a profile with none.
|
|
692
833
|
- **health:** each `health` URL answers; a `warn` at most, since an environment can be down.
|
|
693
834
|
|
|
@@ -715,15 +856,23 @@ The profile's optional `work` section is for the project's work skill; the daemo
|
|
|
715
856
|
}
|
|
716
857
|
```
|
|
717
858
|
|
|
718
|
-
|
|
859
|
+
`conduct install-startup` registers the logon startup, which runs `conduct supervise` (see Restarts) with `node` on the `cli.js` that ran it. Its daemon uses `~/.conductor`, whatever `CONDUCTOR_HOME` says. Each platform has its own (`src/host/startup.ts`, ATK-529):
|
|
860
|
+
|
|
861
|
+
- **Windows:** a Task Scheduler task, `Conductor`, at logon, under `conhost --headless` so no window opens. Registering it starts nothing; `conduct start` runs it.
|
|
862
|
+
- **macOS:** a launchd agent, `~/Library/LaunchAgents/software.detergent.conductor.plist` (`RunAtLoad`), loaded into the login session with `launchctl bootstrap gui/<uid>`, which starts it; `conduct start` runs it with `launchctl kickstart`. Its output goes to `~/.conductor/logs/startup.log`. launchd only loads an agent for a logged-in user, so run `install-startup` in a terminal on the Mac, not over ssh alone.
|
|
863
|
+
- **Linux:** a systemd user unit, `conductor.service` in `~/.config/systemd/user` (`$XDG_CONFIG_HOME` when set), which `install-startup` enables and starts; `conduct start` runs `systemctl --user start conductor`. Its output goes to the journal (`journalctl --user -u conductor`). It starts when your user's service manager does: at login, or at boot once `loginctl enable-linger <you>` is on, which `install-startup` suggests when it isn't. That is how a server nobody logs in to runs the conductor headless.
|
|
864
|
+
|
|
865
|
+
The agent and the unit carry the `PATH` of the terminal that ran `install-startup`, because a login starts them with a short one that has no `claude`, `git` or `pnpm`: run it again after that changes. Neither restarts on its own, since `conduct supervise` does that and `conduct stop` must stick. Neither kills the processes the daemon started when it ends (`AbandonProcessGroup`, `KillMode=process`), so a stop or an upgrade leaves the workers and a tmux server alone, as on Windows. Running `install-startup` again replaces the definition: on macOS and Linux that restarts a daemon the agent or unit started. `--remove` deletes it and leaves a running daemon alone (a launchd agent stays loaded until logout).
|
|
866
|
+
|
|
867
|
+
A global install keeps the `cli.js` path across upgrades, so the startup needs registering once. A Windows task registered before ATK-417 runs the unsupervised `conduct serve`, and `conduct upgrade` says so: run `conduct install-startup` once more. To upgrade:
|
|
719
868
|
|
|
720
869
|
```bash
|
|
721
870
|
conduct upgrade
|
|
722
871
|
```
|
|
723
872
|
|
|
724
|
-
It takes the tag the installed version came from (`dev` for an `X.Y.Z-dev.N`, `latest` otherwise; `--tag` picks one), and does nothing when that tag's version is already installed. When the tag points at an older version (semver, so `1.2.0-dev.4` is older than `1.2.0`), it refuses before it stops anything and names that version; `--allow-downgrade` installs it anyway and reports `downgraded`. Otherwise it stops the daemon, runs `npm install --global @detergent-software/conductor@<the tag's version>` (the exact version it compared, in case the tag moves) and starts the daemon again: through the
|
|
873
|
+
It takes the tag the installed version came from (`dev` for an `X.Y.Z-dev.N`, `latest` otherwise; `--tag` picks one), and does nothing when that tag's version is already installed. When the tag points at an older version (semver, so `1.2.0-dev.4` is older than `1.2.0`), it refuses before it stops anything and names that version; `--allow-downgrade` installs it anyway and reports `downgraded`. Otherwise it stops the daemon, runs `npm install --global @detergent-software/conductor@<the tag's version>` (the exact version it compared, in case the tag moves) and starts the daemon again: through the logon startup when it runs this `cli.js`, else as a detached `conduct supervise --fail-fast`. A supervisor waiting to restart a daemon that keeps dying is stopped the same way before npm runs (see Restarts), so it can't restart `serve` from the `cli.js` npm is replacing, and the conductor starts again on the new version. A daemon that wasn't running stays stopped. If npm fails, the daemon still starts again on whatever npm left in place, and `upgrade` exits 1 with npm's output. A linked build (below) refuses: its `cli.js` isn't under npm's global `node_modules`.
|
|
725
874
|
|
|
726
|
-
**From a checkout instead (development).** `npm link` puts
|
|
875
|
+
**From a checkout instead (development).** `npm link` puts a checkout's build on PATH, so every worktree and the logon startup run that build. Run it from your own checkout of the conductor's repo, and upgrade by pulling `develop`, building, then `conduct stop` and `conduct serve`:
|
|
727
876
|
|
|
728
877
|
```bash
|
|
729
878
|
pnpm install
|
|
@@ -732,7 +881,7 @@ pnpm -C apps/conductor build
|
|
|
732
881
|
conduct install-startup
|
|
733
882
|
```
|
|
734
883
|
|
|
735
|
-
To switch from a linked build to the package: `conduct stop`, `npm rm --global @detergent-software/conductor` (removes the link), install the package as above, and run `conduct install-startup` again so the logon
|
|
884
|
+
To switch from a linked build to the package: `conduct stop`, `npm rm --global @detergent-software/conductor` (removes the link), install the package as above, and run `conduct install-startup` again so the logon startup points at the installed `cli.js`.
|
|
736
885
|
|
|
737
886
|
## Console
|
|
738
887
|
|
|
@@ -741,7 +890,7 @@ The console (design note, section 11) is the one Claude session that stays open
|
|
|
741
890
|
The console belongs to the machine, not to a project (design note v2, section 1): it runs in `<home>/console` (`~/.conductor/console`), a folder of the conductor's own, and opens with no project registered. It reads items through the `conduct` verbs, which find the project from the item while the daemon runs, and a subagent that reads a project's code or logs works in that project's checkout, from `conduct status`.
|
|
742
891
|
|
|
743
892
|
```bash
|
|
744
|
-
conduct console # a tab in Windows Terminal (window "conductor"),
|
|
893
|
+
conduct console # a tab in ~/.conductor/console: Windows Terminal (window "conductor"), iTerm or Terminal, or tmux (session conductor)
|
|
745
894
|
conduct console --here # the same session in this terminal
|
|
746
895
|
```
|
|
747
896
|
|
|
@@ -750,9 +899,6 @@ It writes the plugin copy if none exists yet (see Plugin), then runs `claude --p
|
|
|
750
899
|
**Escalations.** A rule's `escalate console "<ask>"` appends `console.escalated` (the default `conductor-handoff` does it for a worker's `Handoff · conductor` note). The mod wakes the model with `[conductor] escalation #<id> on <item>: <ask>`. The console does the job and reports back with `conduct escalations done <id> "<what it found or did>"`, which logs `console.handled` with the escalation as its cause, so `conduct why <that event>` leads to the rule. With `--ask`, the finding also goes in the inbox as a `console` item, pushed like any other, for something only you can do. `conduct escalations` lists those not yet handled, from the last 14 days, including the ones raised while no console was open; the console reads it when it starts.
|
|
751
900
|
|
|
752
901
|
**With no console open.** The daemon counts the console's streams of `/events` (the mod's `conduct events --follow --console`). When escalations are open and no console stream has been open for 30 s, it raises one `console` inbox item, "escalations wait for the console and none is open: start one with conduct console", pushed like any other. The 30 s cover a console's own reconnects (the mod restarting `conduct events`, a daemon restart). The item resolves when a console opens or the last escalation is handled. The built-in `focus` profile doesn't push the `console` kind. The daemon never opens a console itself: that would wake a model on every escalation, unattended.
|
|
753
|
-
|
|
754
|
-
**Moving from conduct-atk.** Stop the conduct-atk session, `conduct upgrade` (the scheduler now launches by default), then `conduct console`. A machine that keeps conduct-atk's loop sets `"scheduler": { "mode": "observe" }` in `config.json`.
|
|
755
|
-
|
|
756
902
|
## Plugin
|
|
757
903
|
|
|
758
904
|
`plugin/` is the conductor plugin (design note v2, section 4), one Claude Code plugin named `conductor` that holds:
|
|
@@ -781,9 +927,9 @@ The mod is the plugin's hooks module, code that costs no tokens, which does the
|
|
|
781
927
|
- **Every session** sends its plugin report on start, and nothing else: no status line, and no other calls to the daemon.
|
|
782
928
|
- **The console**, the session started with `CONDUCTOR_CONSOLE=1` (`conduct console` starts it; see Console), reads `GET /fleet`, `GET /inbox` and `GET /notify` every 30 s and gets:
|
|
783
929
|
- **the band** above the prompt while the inbox has items: one hotkey per item (a plan approves after a second press, `y`, since a digit typed into an empty prompt presses a band button; an npm OTP copies its `npm stage approve` line; the rest open the pane), and `o` for the pane;
|
|
784
|
-
- **the pane** (`/conductor`, `o`, or opened at the start, docked as a sidebar on a fullscreen terminal of 144 columns or more): the notification profile (a picker; buttons on mobile) and Remote Control's state, the inbox with each item's actions (approve, revise, copy a command, open a link, done; on a Windows terminal, open is a button that opens a web link in the default browser once, since a ctrl+click on a link in a pane opened it twice, ATK-494), the workers by project (each project's rule has [pause] and [focus]), and today's spend. The header counts the workers running and parked and the inbox in words. Each worker is one line in columns that line up across projects, cut to the pane's width so it never wraps: a glyph, the item, its phase, its PR, what it waits on in words (`PR #557 to merge (
|
|
930
|
+
- **the pane** (`/conductor`, `o`, or opened at the start, docked as a sidebar on a fullscreen terminal of 144 columns or more): the notification profile (a picker; buttons on mobile) and Remote Control's state, the inbox with each item's actions (approve, revise, copy a command, open a link, done; on a Windows terminal, open is a button that opens a web link in the default browser once, since a ctrl+click on a link in a pane opened it twice, ATK-494), the workers by project (each project's rule has [pause] and [focus]), and today's spend. The header counts the workers running and parked and the inbox in words. Each worker is one line in columns that line up across projects, cut to the pane's width so it never wraps: a glyph, the item, its phase, its PR, what it waits on in words (`PR #557 to merge (skills-registry)`, `npm 8.9.0-dev.1 on dev (cli)`, `login (ado)`) or else its activity, and the age. As the pane narrows, the age goes first, then the wait, then the PR and the phase (ATK-521). The glyphs are one cell wide: `●` in a turn, `◌` up between turns, `!` stuck on a prompt, `○` parked, `×` failed or gone, `·` starting. A row expands to attach and why, its tokens today, its whole PR ref and its raw wait keys;
|
|
785
931
|
- **commands answered in code**, never by the model: `/conductor`, `/inbox`, `/approve <item | #id>`, `/revise <item | #id> <text>`, `/attach <item | worker>`, `/notify [<profile> [--until <when>]]`, `/pause [<project>] [--until <when>]`, `/unpause [<project>]`, `/focus-project <project> [--until <when>]` (puts its items first, until midnight without `--until`; not `/focus`, which is Claude Code's own);
|
|
786
|
-
- **a check that `/conductor:conductor-console` ran**: a console started without it (by hand, as `claude --plugin-dir … -n conductor`) shows `console without its skill: run /conductor:conductor-console` in the band, and each prompt,
|
|
932
|
+
- **a check that `/conductor:conductor-console` ran**: a console started without it (by hand, as `claude --plugin-dir … -n conductor`) shows `console without its skill: run /conductor:conductor-console` in the band, and each prompt, the operator's or an escalation's, carries a line telling the model to load the skill before it answers. Running the skill (`/conductor:conductor-console`, or the model's Skill tool) clears both, until a `/clear` or a resume starts a conversation without it;
|
|
787
933
|
- **a warm prompt cache while it idles** (ATK-386). The console's cache lasts an hour after its last use, and a wake after that writes the whole context again. So 55 minutes after the console's last turn, the mod sends one ping (`$.model.fork`, the console's own last request plus a one-word ask, no tools, no turn in the transcript). The API serves the ping from the cache for a tenth of a write's price, and the hour starts over. It pings again every 55 minutes until a turn comes, and each turn starts the timer over. At an idle point it runs `/clear` instead when the context is 150k or more, or after 12 pings in a row (the 13th idle point, about 12 hours after the last turn), and the band says `fresh console (cleared while idle: …)` until the next prompt loads the skill. A ping that read less than half the context from the cache (the cache was gone anyway, as under a 5-minute TTL) stops the pings until the next turn. Each ping and clear goes to `POST /console/cache`, which logs a `console.cache` event with the context size and what the ping cost. A fork isn't in the transcript, so that event is the only place its cost shows;
|
|
788
934
|
- **the stream**: it runs `node <the cli.js that conductor.json names> events --follow --console` (`--console` tells the daemon a console is open), redraws on each event, toasts each new inbox item, and acts on three events:
|
|
789
935
|
|