@detergent-software/conductor 1.0.0-dev.2 → 1.0.0-dev.20
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 +163 -0
- package/README.md +361 -11
- package/build/broker-cli.js +125 -0
- package/build/broker-cli.js.map +1 -0
- package/build/broker.js +154 -0
- package/build/broker.js.map +1 -0
- package/build/cli.js +610 -44
- package/build/cli.js.map +1 -1
- package/build/clock.js +17 -0
- package/build/clock.js.map +1 -0
- package/build/conditions.js +19 -13
- package/build/conditions.js.map +1 -1
- package/build/config.js +94 -1
- package/build/config.js.map +1 -1
- package/build/console.js +99 -0
- package/build/console.js.map +1 -0
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +153 -2
- package/build/daemon.js.map +1 -1
- package/build/events.js +106 -0
- package/build/events.js.map +1 -0
- package/build/history.js +630 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +386 -0
- package/build/inbox.js.map +1 -0
- package/build/manager.js +74 -12
- package/build/manager.js.map +1 -1
- package/build/mod.js +51 -0
- package/build/mod.js.map +1 -0
- package/build/note.js +71 -0
- package/build/note.js.map +1 -0
- package/build/notes.js +172 -0
- package/build/notes.js.map +1 -0
- package/build/notify.js +422 -0
- package/build/notify.js.map +1 -0
- package/build/overrides.js +83 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +10 -1
- package/build/paths.js.map +1 -1
- package/build/profile.js +68 -2
- package/build/profile.js.map +1 -1
- package/build/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- package/build/queue.js +183 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +17 -10
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +444 -0
- package/build/rule-actions.js.map +1 -0
- package/build/rule-input.js +17 -0
- package/build/rule-input.js.map +1 -0
- package/build/rules-service.js +402 -0
- package/build/rules-service.js.map +1 -0
- package/build/rules.js +850 -0
- package/build/rules.js.map +1 -0
- package/build/scheduler.js +274 -0
- package/build/scheduler.js.map +1 -0
- package/build/server.js +294 -0
- package/build/server.js.map +1 -1
- package/build/spend.js +8 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +452 -4
- package/build/store.js.map +1 -1
- package/build/trackers.js +12 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +36 -22
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +35 -41
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +55 -0
- package/build/watchers/ado-api.js.map +1 -0
- package/build/watchers/ado.js +37 -49
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-queue.js +120 -0
- package/build/watchers/boards-queue.js.map +1 -0
- package/build/watchers/boards-writes.js +83 -0
- package/build/watchers/boards-writes.js.map +1 -0
- package/build/watchers/boards.js +267 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +1 -0
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +55 -0
- package/build/watchers/linear-api.js.map +1 -0
- package/build/watchers/linear-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- package/build/watchers/linear-labels.js +43 -0
- package/build/watchers/linear-labels.js.map +1 -0
- package/build/watchers/linear-queue.js +96 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +29 -18
- package/build/watchers/linear.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/marketplace.json +6 -0
- package/mod/.claude-plugin/plugin.json +7 -0
- package/mod/README.md +17 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/model.ts +457 -0
- package/mod/hooks/register.tsx +543 -0
- package/mod/types/index.d.ts +57 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -1,23 +1,63 @@
|
|
|
1
1
|
# Conductor
|
|
2
2
|
|
|
3
|
-
`@detergent-software/conductor`: the local daemon that runs the work loop for every conducted project on this machine, and its `conduct` CLI. The design, decisions and build order are in the Linear document *Conductor: design note* (project *Conductor daemon*). This package has build step 1, the skeleton (ATK-308), step 2, the session manager (ATK-309),
|
|
3
|
+
`@detergent-software/conductor`: the local daemon that runs the work loop for every conducted project on this machine, and its `conduct` CLI. The design, decisions and build order are in the Linear document *Conductor: design note* (project *Conductor daemon*). This package has build step 1, the skeleton (ATK-308), step 2, the session manager (ATK-309), step 3, the watchers and `conduct wait` (ATK-310), the daemon's side of step 4, worker protocol v1's `conduct note` (ATK-311), step 5, the inbox with `conduct approve` / `revise` (ATK-312), step 6, the notifier (ATK-313), step 9, the conductor mod for Claude Code in `mod/` (ATK-265), step 8, the scheduler, settings and rules (ATK-315), the daemon's side of step 7, the console (ATK-314), step 12, the port broker (ATK-318), and step 13, the ADO Boards tracker (ATK-319).
|
|
4
4
|
|
|
5
5
|
## Commands
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
8
|
conduct serve run the daemon in this process (one per machine)
|
|
9
|
+
conduct start start the daemon detached and wait until it answers (see Console)
|
|
9
10
|
conduct stop stop the running daemon
|
|
10
|
-
conduct status [--json] the daemon's pid, port, projects and
|
|
11
|
+
conduct status [--json] the daemon's pid, port, projects, workers and held locks
|
|
11
12
|
conduct project add <path> register the project whose checkout holds .claude/conductor.json
|
|
12
13
|
conduct project list [--json] the registered projects
|
|
13
14
|
conduct launch <item> start a worker: worktree, background session, tab
|
|
14
15
|
[--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
|
|
15
16
|
conduct retire <worker> [--force] stop a worker, remove its worktree and session
|
|
16
17
|
conduct attach <worker> wake a parked worker (no event text) and open it in a tab
|
|
18
|
+
conduct inbox [--json] what waits on you, with its commands and links (see Inbox)
|
|
19
|
+
conduct approve <item> [--text <text>]
|
|
20
|
+
approve a plan: post "approve" on the item as you, wake its worker
|
|
21
|
+
conduct revise <item> <text> ask for another plan: post "revise: <text>", wake its worker
|
|
22
|
+
conduct done <id> mark an inbox item done (a physical action, or one you handled)
|
|
23
|
+
conduct notify [--json] the notification profile, quiet hours, each channel's state
|
|
24
|
+
conduct notify profile [<name>] [--until <when>]
|
|
25
|
+
switch where pushes go (see Notifications)
|
|
26
|
+
conduct notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
|
|
27
|
+
push a sample item now, or raise a real one with --raise
|
|
28
|
+
conduct queue [--json] [--fresh] what the scheduler picks next and why the rest wait (see Scheduler)
|
|
29
|
+
conduct pause [<project>] [--until <when>]
|
|
30
|
+
no new launches in a project, or in all of them, until a time
|
|
31
|
+
conduct unpause [<project>] launch again
|
|
32
|
+
conduct focus <project> [--slots <n>] [--weight <n>] --until <when>
|
|
33
|
+
a project's slots or ordering weight, for a while (neither: it goes first)
|
|
34
|
+
conduct why <event id | item> [--json]
|
|
35
|
+
the rule behind an action, and the events that led to it
|
|
36
|
+
conduct console [--project <name>] [--here]
|
|
37
|
+
open the console in a Windows Terminal tab, or in this terminal (see Console)
|
|
38
|
+
conduct escalations [--json] what rules escalated to the console and it hasn't handled
|
|
39
|
+
conduct escalations done <id> [--ask] <text>
|
|
40
|
+
the console's finding on an escalation; --ask puts it in the inbox
|
|
41
|
+
conduct history [<range>] [--since <when>] [--until <when>] [--project <name>] [--json]
|
|
42
|
+
what a window of the log says: shipped, phases, waits, stalls, spend (see History)
|
|
43
|
+
conduct retro [<range>] [--project <name>]
|
|
44
|
+
the same as a Markdown report
|
|
45
|
+
conduct rules [--json] the rules in force, what replaces what, any file refused (see Rules)
|
|
46
|
+
conduct rules test <file> [--json]
|
|
47
|
+
dry-run the rules on a sample event; acts on nothing
|
|
48
|
+
conduct note <item> <file> post a phase note on the item and record its phase (see Notes)
|
|
49
|
+
[--worker <worker>]
|
|
17
50
|
conduct wait <condition> wait for an outside event (see Waits)
|
|
51
|
+
conduct ports [<path>] [--prefer <slot>] [--json]
|
|
52
|
+
a checkout's dev ports from the port broker, or every worktree's (see Broker)
|
|
53
|
+
conduct lock acquire|release|status <name> [--owner <id>] [--wait <minutes>]
|
|
54
|
+
a lock every project shares, such as chrome (see Broker)
|
|
55
|
+
conduct events [--since <id>] [--follow]
|
|
56
|
+
the event log, one JSON event per line (see API)
|
|
18
57
|
conduct install-startup start the daemon at logon (Windows Task Scheduler)
|
|
19
58
|
conduct install-startup --remove remove that logon task
|
|
20
|
-
conduct
|
|
59
|
+
conduct install-mod [--remove] install the conductor mod in Claude Code (see Mod)
|
|
60
|
+
conduct upgrade [--tag dev|latest] [--allow-downgrade]
|
|
21
61
|
stop the daemon, npm install the tag's newest version, start it again
|
|
22
62
|
```
|
|
23
63
|
|
|
@@ -34,12 +74,168 @@ The session manager owns the `claude --bg` sessions it launches, and only those:
|
|
|
34
74
|
- **restarts:** a launch the daemon was stopped in adopts its session if one was started, and otherwise fails and removes its clean worktree.
|
|
35
75
|
- **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`). The daemon can't see the tab, so this also delivers a wait that resolved while it was attached.
|
|
36
76
|
- **park failures:** a session whose process outlives `claude stop` by 30 s logs one `worker.park-failed` per spell, and the park is tried again every 5 minutes.
|
|
37
|
-
- **stalls:** a permission prompt open `sessions.permissionMinutes` (5), or idle `sessions.idleMinutes` (20) with no wait, logs one `worker.stalled` event per spell.
|
|
38
|
-
- **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.
|
|
77
|
+
- **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.
|
|
78
|
+
- **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.
|
|
39
79
|
- **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.
|
|
40
80
|
|
|
41
81
|
Worker statuses: `launching`, `running`, `parked`, `stopped-unexpectedly`, `failed`, `exited`. Every step is an event (`worker.launched`, `worker.parked`, `worker.resumed`, …) on `GET /events`.
|
|
42
82
|
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
A worker writes each phase note (`## <work skill> · <Phase>[ · <kind>: <one line>]`, such as `## work-issue · 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. 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 `key`) is the item's key prefix.
|
|
86
|
+
|
|
87
|
+
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 (ATK's `work-issue` became `work-item`), the old name goes in `aliases` and items in flight keep their phases.
|
|
88
|
+
|
|
89
|
+
| Exit | Means | The worker then |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| 0 | posted | goes on |
|
|
92
|
+
| 3 | nobody posted it: no daemon, one without notes, or the tracker refused it (such as a read-only key) | posts the note to the tracker itself, as before protocol v1 |
|
|
93
|
+
| 1 | refused (no heading, no project) or unclear (the daemon or Linear didn't answer the write) | reads the message; when it says the note may be there, looks before posting again |
|
|
94
|
+
|
|
95
|
+
On Linear the daemon uses the key the watchers read (below), so that key needs **Create comments** as well as Read. Comments it writes carry the key owner's name, as the Linear MCP's do. On ADO Boards it posts a markdown discussion comment as the `az login` identity.
|
|
96
|
+
|
|
97
|
+
## Inbox
|
|
98
|
+
|
|
99
|
+
The `inbox` table holds everything that waits on Jason, across projects. Each item records:
|
|
100
|
+
|
|
101
|
+
- its kind;
|
|
102
|
+
- the item and worker it is about;
|
|
103
|
+
- one line of text;
|
|
104
|
+
- the exact commands and links;
|
|
105
|
+
- when it was raised.
|
|
106
|
+
|
|
107
|
+
An item resolves itself when its condition is seen, and records what resolved it. `inbox.raised` and `inbox.resolved` events go on `GET /events`. The notifier pushes each new item to the phone (see Notifications).
|
|
108
|
+
|
|
109
|
+
| Kind | Raised by | Resolved by |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `plan` | a `Plan` note (`conduct note`), or an `approval` wait | the approval wait resolving (an `approve` or `revise:` comment), a `Plan approved` note, a newer Plan, or `conduct approve` / `revise` |
|
|
112
|
+
| `merge` | a `Handoff · merge: <PR>` note; a `pr … merged` wait attaches to it | its PR wait resolving as merged, abandoned or closed, or a `Landed` note. A PR wait without the note raises nothing, because auto landing needs nobody |
|
|
113
|
+
| `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 |
|
|
114
|
+
| `jason`, `human`, `scope`, `blocked`, `abandoned`, `failed` | a Handoff note of that kind | any later note on the item (such as `Resumed`), or `conduct done` |
|
|
115
|
+
| `permission`, `stall` | `worker.stalled` | `worker.stall-cleared`, a park or resume, a new wait (stall only), or a daemon restart (it is raised again if it persists) |
|
|
116
|
+
| `failed` | a failed launch, or a session that stopped unexpectedly | a new launch, the worker running again, or its retirement |
|
|
117
|
+
| `runaway` | a worker passing `inbox.runawayTokens` (2M) input, output and cache-write tokens in all, once per worker | `conduct done`, or its retirement |
|
|
118
|
+
| `ready-to-release`, `queue-drained` | the scheduler's default rules (see Scheduler) | `conduct done` |
|
|
119
|
+
| `console` | the console's finding on an escalation, with `conduct escalations done <id> --ask` (see Console) | `conduct done` |
|
|
120
|
+
|
|
121
|
+
Nothing raises `prod-approval` yet; `/release-atk` will. When a worker is retired, its open items resolve with it.
|
|
122
|
+
|
|
123
|
+
For an item raised from a note:
|
|
124
|
+
|
|
125
|
+
- 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.
|
|
126
|
+
- The links are the comment's URL and every URL in the note.
|
|
127
|
+
|
|
128
|
+
`conduct approve ATK-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 Jason's name and counts as his answer. `conduct revise ATK-312 "<text>"` posts `revise: <text>`; every word after the item is the text, quoted or not. Either one then:
|
|
129
|
+
|
|
130
|
+
1. resolves the plan item and the worker's open `approval` wait;
|
|
131
|
+
2. has the session manager resume a parked worker with `[conductor] event: approval ATK-312: approve HH:MM`. The worker reads the comment on the tracker before it acts.
|
|
132
|
+
|
|
133
|
+
If the tracker refuses the comment, nothing is resolved. Only a plan takes approve or revise; `conduct done <id>` resolves any item.
|
|
134
|
+
|
|
135
|
+
## Notifications
|
|
136
|
+
|
|
137
|
+
The notifier routes each new inbox item to the phone, on two channels:
|
|
138
|
+
|
|
139
|
+
- **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.
|
|
140
|
+
- **Pushover:** sent from the daemon. The keys are `PUSHOVER_TOKEN` and `PUSHOVER_USER`, or else the Windows generic credential `atk-conductor-pushover` (`notify.pushover.credential`): its **user name is your Pushover user key** and its **password is the application's API token**. Save it with `cmdkey /generic:atk-conductor-pushover /user:<user key> /pass:<app token>` or in Credential Manager. A credential saved while the daemon runs is read on the next push. The keys are never logged or stored.
|
|
141
|
+
|
|
142
|
+
**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:
|
|
143
|
+
|
|
144
|
+
```jsonc
|
|
145
|
+
{
|
|
146
|
+
"quietHours": { "from": "22:00", "to": "07:00" }, // local; omit for none
|
|
147
|
+
"notify": {
|
|
148
|
+
"profile": "default", // the profile when none is switched to
|
|
149
|
+
"profiles": {
|
|
150
|
+
"default": [{ "to": ["remote-control", "pushover"] }], // built in
|
|
151
|
+
"away": [{ "to": ["pushover"] }], // built in
|
|
152
|
+
"focus": [{ "kinds": ["npm-otp", "permission", "failed"], "to": ["remote-control", "pushover"] }], // built in
|
|
153
|
+
"workday": [
|
|
154
|
+
{ "projects": ["cadence"], "days": ["sat", "sun"], "to": [] }, // keep these off every channel
|
|
155
|
+
{ "kinds": ["npm-otp"], "to": ["pushover", "remote-control"], "remind": { "afterMinutes": 10, "times": 5 } },
|
|
156
|
+
{ "hours": "08:00-18:00", "to": ["remote-control"] }
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
- **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: ATK-1 npm-otp, ATK-2 plan; 1 resolved meanwhile`, routed by the profile then in force. `conduct notify test` pushes at any hour.
|
|
166
|
+
- **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.
|
|
167
|
+
- **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.
|
|
168
|
+
- **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`.
|
|
169
|
+
|
|
170
|
+
`conduct notify` shows the profile, quiet hours and the last state of each channel: Pushover from its own answers, 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.
|
|
171
|
+
|
|
172
|
+
## Rules
|
|
173
|
+
|
|
174
|
+
Rules say what the conductor does when something happens (design note, section 9). Each is a `when` and a `do` in a fixed vocabulary, written in JSONC:
|
|
175
|
+
|
|
176
|
+
```jsonc
|
|
177
|
+
// ~/.conductor/rules.json
|
|
178
|
+
[
|
|
179
|
+
{ "when": "wait.kind == merge and wait.age > 2h", "do": "remind jason" },
|
|
180
|
+
{ "when": "plan.tier == S and item.area in [docs, ops]", "do": "auto-approve after 15m" },
|
|
181
|
+
{ "id": "runaway", "when": "spend.worker > 5M", "do": "inbox runaway" }, // replaces the default
|
|
182
|
+
{ "id": "daily-cap", "do": "none" }, // switches a default off
|
|
183
|
+
{ "when": "project == web and day == saturday", "do": "pause launches" }
|
|
184
|
+
]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- **Sources, in order:** the item's project's `rules` (in its `.claude/conductor.json`, changed by PR), then `~/.conductor/rules.json`, then the built-in defaults: conduct-atk's behavior written as rules (`launch`, `retire-closed`, `park-on-wait`, `resume-on-wait`, `runaway`, `daily-cap`, `ready-to-release`, `queue-drained`, and `conductor-handoff`, which escalates a worker's `Handoff · conductor` note to the console). A rule with an `id` replaces the rule with the same id further down, so a file holds only the differences.
|
|
188
|
+
- **when:** `and`, `or`, `not` and parentheses over comparisons (`==`, `!=`, `<`, `<=`, `>`, `>=`, `in [a, b]`, `<label> in item.labels`) and events. An event rule fires when one of its events happens: `note`, `plan`, `wait.resolved`, `run.failed`, `worker.idle`, `worker.exited`, `worker.permission`, `worker.waiting`, `inbox.raised`, `pr.comment`, `item.unblocked`, `item.closed`, `queue.pick`, `queue.drained`, `queue.ready-to-release`. 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`, `plan.tier`, `note.phase/kind`, `wait.kind/age`, `inbox.kind/age`, `worker.retries/phase`, `spend.worker/item/project/today`, `time`, `day`, `notify.profile`. Values carry units: `15m`, `2h`, `500k`, `2M`, `22:00`, `saturday`.
|
|
189
|
+
- **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 after <duration>`, `inbox <kind> ["<text>"]`, `remind jason`, `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.
|
|
190
|
+
- **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`, `retry` outside `run.failed`. `merge`, `publish`, `approve`, `delete` and `run` aren't in the vocabulary.
|
|
191
|
+
- **Reload:** saving `rules.json` or a project's profile reloads its rules. A profile save takes only the `rules`; the rest of a profile changes with `conduct project add`. A file the conductor refuses leaves its last good rules 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.
|
|
192
|
+
- **Checking:** `conduct rules` lists the rules in force (`project.atk:#2`, `machine:runaway`, `default:launch`) and what replaces what. `conduct rules test <file>` dry-runs a sample, such as `{ "event": "pr.comment", "project": "atk", "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.
|
|
193
|
+
|
|
194
|
+
- **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 ATK-315` does it for the item's newest actions.
|
|
195
|
+
|
|
196
|
+
**How actions are carried out.** Every action is a `rule.fired` event that names its rule, then `rule.done` or `rule.failed`.
|
|
197
|
+
|
|
198
|
+
- **The scheduler** does `launch` on `queue.pick` and `retire` on `item.closed` (see Scheduler).
|
|
199
|
+
- **The session manager** asks the rules before each `park` and `resume`. A resume for a review comment is also a `pr.comment`.
|
|
200
|
+
- **Event rules** run on the daemon's events.
|
|
201
|
+
- **State rules** are checked each minute against every wait, inbox item, worker, project and the machine. Each fires once while it holds and again after it stops holding; a restart remembers which hold. A `pause launches` with no end, from a state rule, lifts when the rule stops holding (`rule.released`), and only the pause that rule set.
|
|
202
|
+
|
|
203
|
+
What each action does:
|
|
204
|
+
- `remind jason` pushes the subject's inbox item again: the wait's gate, or the worker's item.
|
|
205
|
+
- `notify <channel>` pushes once. `notify profile` switches the profile.
|
|
206
|
+
- `inbox <kind>` raises an item, once while it is open. `inbox runaway` is raised once per worker.
|
|
207
|
+
- `pause` / `unpause` / `focus` set overrides that name the rule.
|
|
208
|
+
- `label add|remove` edits one label on the item (the profile's human label for `human`), at most 20 times a day per item.
|
|
209
|
+
- `escalate console` emits `console.escalated`, which wakes the console's model; the console reports back with `conduct escalations done` (see Console).
|
|
210
|
+
- `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.
|
|
211
|
+
- `auto-approve after N` pushes "auto-approves at HH:MM" where plans go. 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. No default rule auto-approves. A Plan note's `**Size tier: S**` gives `plan.tier` (and `item.tier` later).
|
|
212
|
+
- `launch` on `item.unblocked` brings the scheduler's next tick forward.
|
|
213
|
+
- `launch` and `retry` start sessions, so they wait for `scheduler.mode` `on`, as the scheduler's own launches do. In `observe` they are logged, not done; every other action acts in any mode.
|
|
214
|
+
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
## Scheduler
|
|
218
|
+
|
|
219
|
+
The scheduler moves conduct-atk's tick into 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.
|
|
220
|
+
|
|
221
|
+
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.
|
|
222
|
+
2. **Plan.** It works out the picks across projects, with the same order and reasons as conduct-atk's `planQueue`. A replay of conduct-atk's own Linear read is in the tests.
|
|
223
|
+
- **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.
|
|
224
|
+
- **Areas:** one in-flight item per Area per project. An Area is held by every worker in flight and every started item, and by the Areas it covers or is covered by.
|
|
225
|
+
- **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).
|
|
226
|
+
- **Slots:** `scheduler.slots` (4) across projects and the profile's `slots` (2) 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.
|
|
227
|
+
- **Holds:** quiet hours and `conduct pause` turn picks into waits that say why.
|
|
228
|
+
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), `queue.drained` and `queue.ready-to-release` → an inbox item once a day. 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.
|
|
229
|
+
|
|
230
|
+
`scheduler.mode` decides how far it goes:
|
|
231
|
+
- **`on`** (the default): it acts.
|
|
232
|
+
- **`observe`:** it reads, plans and logs each action once, and does none of them. Set it on a machine where conduct-atk's loop still launches.
|
|
233
|
+
- **`off`:** it doesn't run; `conduct queue --fresh` still works out a plan on request.
|
|
234
|
+
|
|
235
|
+
`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. 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.
|
|
236
|
+
|
|
237
|
+
**Overrides** end on their own: `conduct pause atk --until 17:00`, `conduct pause --until tomorrow` (every project), `conduct unpause atk`, `conduct focus atk --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`.
|
|
238
|
+
|
|
43
239
|
## Waits
|
|
44
240
|
|
|
45
241
|
A worker waits by posting its note, running `conduct wait …` and ending the turn (worker protocol v1). The verbs take the repo, host and package from the worktree's `.claude/conductor.json`; the full form is accepted too, and is what the store keeps:
|
|
@@ -53,8 +249,8 @@ A worker waits by posting its note, running `conduct wait …` and ending the tu
|
|
|
53
249
|
| `run atk-api-deploy 12345 done` | `run:ado:atk-api-deploy#12345:done` | the run completed, with its result |
|
|
54
250
|
| `npm 8.5.0-dev.2 --tag dev` | `npm:@detergent-software/atk@8.5.0-dev.2:dev` | the dist-tag points at the version |
|
|
55
251
|
| `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 |
|
|
56
|
-
| `approval` | `approval:linear:ATK-310` | an `approve` / `revise:` comment by the Linear key's owner after the item's newest `<work skill> · Plan` note |
|
|
57
|
-
| `item ATK-271 done` / `label human` / `note Landed` | `item:linear:ATK-271:state=done` | the state (name or type), a label, or a phase note |
|
|
252
|
+
| `approval` | `approval:linear:ATK-310` (`approval:ado-boards:CAD-12` on Boards) | an `approve` / `revise:` comment by the Linear key's owner (on Boards, the `az login` identity) after the item's newest `<work skill> · Plan` note |
|
|
253
|
+
| `item ATK-271 done` / `label human` / `note Landed` | `item:linear:ATK-271:state=done` | the state (name or type), a label (a tag on Boards), or a phase note |
|
|
58
254
|
| `time 2026-10-10T09:00` | `time:2026-10-10T14:00:00.000Z` | the time has passed (no zone means local time) |
|
|
59
255
|
|
|
60
256
|
- **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 profile as registered (`conduct project add`), and refuses one it can't check, such as a `health` environment the profile has no URL for.
|
|
@@ -62,13 +258,94 @@ A worker waits by posting its note, running `conduct wait …` and ending the tu
|
|
|
62
258
|
|
|
63
259
|
**Watchers.** One per API: Linear (GraphQL with `LINEAR_API_KEY` or the `atk-conductor-linear` credential, ported from conduct-atk), Azure DevOps (REST with the `az login` token), GitHub (`gh api`), npm (the registry's dist-tags, no cache) and `/health`. Time waits are the session manager's sweep. An API is asked nothing while no wait needs it. Otherwise it's asked once per `watchers.<api>.intervalSeconds`, within `perMinute` requests in any rolling minute shared by every project. Each pass covers all of that API's waits in as few requests as it can; Linear's is one request, filtered on `updatedAt` (with a 2-minute overlap) after the first. A pass that runs out of budget stops, and the next one starts a wait further on. A failing API waits twice as long each time (up to 10 minutes) and logs `watcher.failed` once, then `watcher.recovered`. A wait that can't be checked logs `wait.check-failed` once and stays open. Every `watchers.statsMinutes` (10) in which anything waited, a `watcher.stats` event records each API's passes, requests, budget skips, failures and waits.
|
|
64
260
|
|
|
261
|
+
## ADO Boards
|
|
262
|
+
|
|
263
|
+
A project whose work items live in Azure DevOps Boards (Cadence) sets `tracker.kind` to `ado-boards` (design note, section 12; ATK-319):
|
|
264
|
+
|
|
265
|
+
```jsonc
|
|
266
|
+
"tracker": {
|
|
267
|
+
"kind": "ado-boards",
|
|
268
|
+
"key": "CAD", // items are CAD-<work item id>: ADO's ids are numbers alone
|
|
269
|
+
"query": "Shared Queries/Conduct", // the saved query that is the queue, by path or id
|
|
270
|
+
"humanLabel": "human", // a tag here
|
|
271
|
+
"priority": 3, // the queue's priority against other projects' (1 urgent … 4 low)
|
|
272
|
+
"areaRoot": "Emergent Software\\Cadence", // optional: an item's Area is its Area Path below it
|
|
273
|
+
"states": { "Blocked": "started" } // optional, over the built-in state names
|
|
274
|
+
// "organization", "project": optional, the code host's by default
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
| Tracker concept | On Boards |
|
|
279
|
+
|---|---|
|
|
280
|
+
| the queue | the saved query, a flat list, in its own order (by default the items tagged `conduct` that aren't Done or Removed, by Priority, then Backlog Priority; leaving closed items out keeps each read small). Its open items, and the in-flight ones, are one tracker project named after the query, with the profile's `priority`; within a priority the scheduler keeps the query's order. |
|
|
281
|
+
| Area | the Area Path below `areaRoot` (`Web/Forms`); an item at the root holds none |
|
|
282
|
+
| blockers | Predecessor links whose item is still open, inside the query or not |
|
|
283
|
+
| human, labels | tags |
|
|
284
|
+
| states | `New`, `Approved`, `To Do`, `Proposed` → unstarted; `Committed`, `Active`, `In Progress`, `Resolved` → started; `Done`, `Closed` → completed; `Removed` → canceled; `states` adds or overrides. A state neither names is never launched or retired. |
|
|
285
|
+
| phase notes, `approve` / `revise:` | discussion comments: notes are posted as markdown; a comment typed in the web UI (HTML) is read as its text |
|
|
286
|
+
|
|
287
|
+
Everything goes through the `az login` token. The reads (the scheduler's queue and the watchers' waits) take from the `ado` watcher's budget, as PR and pipeline waits do. A wait pass reads every waited item of a board in one batch, and an item's comments only when its rev or comment count moved. Notes and the rules' `label` edits (a patch of `System.Tags` alone, refused if the item changed since it was read) are writes, outside the budget, as on Linear.
|
|
288
|
+
|
|
289
|
+
## Broker
|
|
290
|
+
|
|
291
|
+
The broker shares what every project on the machine uses (ATK-318).
|
|
292
|
+
|
|
293
|
+
**Ports.** Each profile declares its dev servers' base ports by name, such as ATK's `"ports": { "web": 3000, "api": 7071, "maxSlot": 50 }` or `{ "web": 3000 }`.
|
|
294
|
+
- A main checkout serves on the base ports (slot 0).
|
|
295
|
+
- Each worktree gets a slot from 1 to `maxSlot` (default 50) and serves on base + slot. `conduct ports <path>` gives it the first slot whose ports no other checkout of any registered project has and nothing listens on, `--prefer` first. It's the same answer each time after that.
|
|
296
|
+
- A retired worker's worktree frees its slot, and so does a worktree whose directory is gone (at the next assignment).
|
|
297
|
+
- Ports are compared, not slots, so projects with different bases stay apart too.
|
|
298
|
+
- A project's dev scripts read the assignment with `--json`, as ATK's `scripts/dev-ports.mjs assign` does. They keep their own scheme when it exits 3: no daemon, an older one, or a checkout in no registered project.
|
|
299
|
+
- The broker knows only the slots it gave out. A slot a script picked on its own while no daemon answered stays unknown until that worktree asks the broker; until then only the listening probe keeps the slot clear. So `--prefer` is the slot the script would pick itself (`dev-ports.mjs` passes the checkout's own slot or its own free-slot scan's). The preferred slot is taken without the listening probe, because the checkout's own servers may be what listens there.
|
|
300
|
+
- A stored assignment follows its project's profile: when the base ports or `maxSlot` change, the worktree moves, its old slot first.
|
|
301
|
+
|
|
302
|
+
**Locks.** `conduct lock acquire chrome` takes the lock every project's verify runs share. It answers the work-issue helper's JSON (`acquired`, `heldBy`, `since`, `refreshed`; `released` for a release; `verdict` for a status).
|
|
303
|
+
- The owner is `--owner`, else the item of the worker whose worktree this is.
|
|
304
|
+
- Acquiring again refreshes it. 30 minutes after its holder last acquired it, another owner may take it over.
|
|
305
|
+
- `--wait <minutes>` asks again every 15 s until then.
|
|
306
|
+
- Retiring a worker releases the locks its item holds (a probe's retire doesn't).
|
|
307
|
+
- `conduct status` lists the locks held, each with its holder, its last acquire and whether it is stale.
|
|
308
|
+
- Exit 3 when no daemon answers.
|
|
309
|
+
|
|
310
|
+
Events: `ports.assigned`, `ports.freed`, `lock.acquired`, `lock.released`, `lock.taken-over`.
|
|
311
|
+
|
|
312
|
+
## History
|
|
313
|
+
|
|
314
|
+
`conduct history` and `conduct retro` read a window of the log and say what happened in it, so a review of a conducted run needs no transcripts. Both are queries over `conductor.db`, so they work without a daemon too (or with one older than this build).
|
|
315
|
+
|
|
316
|
+
- **Window.**
|
|
317
|
+
- `<range>` is `today`, `yesterday`, a date (`2026-10-10`, that day), `A..B` (`2026-10-10..2026-10-11`, through B), or a span back from now (`24h`, the default; `7d`; `90m`).
|
|
318
|
+
- Instead of a range, `--since` and `--until` take a time each, such as `2026-10-11T09:00`, `yesterday` or `6h`.
|
|
319
|
+
- Times are local, and a window never reaches past now.
|
|
320
|
+
- **What it measures:**
|
|
321
|
+
- **Shipped:** items with a Landed note in the window, with the time from their Started note.
|
|
322
|
+
- **Phases:** the time from each lifecycle note (Started, Done when or Repro, Plan, Plan approved, Verified, PR opened, Landed) to the item's next one, clipped to the window. Results are grouped by the tier on the item's newest Plan note (`?` without one). Handoff, Progress and Resumed notes fall inside a phase.
|
|
323
|
+
- **Plan latency:** from the newest Plan note to Plan approved, so a revision restarts the clock, plus the plans still waiting.
|
|
324
|
+
- **Waits:** the waits registered in the window, by what they held up: `plan`, `merge`, `checks`, `review`, `ci`, `npm-otp`, `time`, `item` and `health`.
|
|
325
|
+
- One PR event often resolves its merged, checks and comment waits together; they count once, under what happened.
|
|
326
|
+
- An open wait counts up to the window's end.
|
|
327
|
+
- Waits the manager closed itself (`worker retired`, `launch failed`) are counted apart.
|
|
328
|
+
- **Stalls and failures:**
|
|
329
|
+
- `worker.stalled` events, and Handoff notes by kind;
|
|
330
|
+
- failed launches, resumes and parks, workers stopped unexpectedly, refused or partial retires, failed rules and watchers;
|
|
331
|
+
- items launched more than once;
|
|
332
|
+
- unfinished items with their phase.
|
|
333
|
+
- **Inbox:** the items raised, by kind, with how long each took to resolve and how it was resolved: `conduct approve`/`revise`/`done`, a later note, the worker retired, or the condition seen.
|
|
334
|
+
- **Slots:** peak and average running workers, and running and parked hours by project and item.
|
|
335
|
+
- A slot is counted as the scheduler counts it, so a worker left attached and idle holds its slot the whole time.
|
|
336
|
+
- **Spend:** tokens by project and item. `spend` is kept per local day, so this covers the whole days the window touches.
|
|
337
|
+
- Tokens are input, output and cache writes; cache reads have their own column.
|
|
338
|
+
- **Rules:** how often each rule fired, and how its actions went.
|
|
339
|
+
- `conduct retro` prints the same as Markdown. The console can post it on a Linear issue, or it can be pasted anywhere.
|
|
340
|
+
|
|
65
341
|
## Files
|
|
66
342
|
|
|
67
343
|
Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
68
344
|
|
|
69
345
|
| File | Holds |
|
|
70
346
|
|---|---|
|
|
71
|
-
| `config.json` | machine settings, JSONC (comments allowed), validated at startup; `sessions` holds `pollSeconds` (10), `settleSeconds` (5), `permissionMinutes` (5), `idleMinutes` (20); `watchers` holds `ado`, `github`, `health`, `linear` and `npm`, each `{ intervalSeconds, perMinute }` (30/60, 30/30, 30/30, 30/10, 20/30), and `statsMinutes` (10) |
|
|
347
|
+
| `config.json` | machine settings, JSONC (comments allowed), validated at startup; `inbox.runawayTokens` (2000000, the `runaway` rule's number); `notify` and `quietHours` (see Notifications); `scheduler` holds `mode` (`on`, `observe` 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); `watchers` holds `ado`, `github`, `health`, `linear` and `npm`, each `{ intervalSeconds, perMinute }` (30/60, 30/30, 30/30, 30/10, 20/30), and `statsMinutes` (10) |
|
|
348
|
+
| `rules.json` | machine rules, JSONC, reloaded on save (see Rules) |
|
|
72
349
|
| `token` | the API token, readable by this user only; every request sends `Authorization: Bearer <token>` |
|
|
73
350
|
| `conductor.pid`, `port` | the single-instance lock and where the API listens (`127.0.0.1`) |
|
|
74
351
|
| `conductor.db` | SQLite state, including the append-only `events` log |
|
|
@@ -77,12 +354,33 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
|
77
354
|
## API
|
|
78
355
|
|
|
79
356
|
- `GET /projects`: the registered projects. `POST /projects` with `{ "path": "<absolute path>" }` registers one.
|
|
80
|
-
- `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.
|
|
357
|
+
- `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":…}`.
|
|
81
358
|
- `POST /shutdown`: a clean stop (`conduct stop`).
|
|
359
|
+
- `GET /console/escalations`: the open escalations, oldest first (see Console).
|
|
360
|
+
- `POST /console/escalations/:id` with `{ "text": "<finding>", "ask"?: true }`: the console's report on one; 201 with the escalation, the `console.handled` event's id and the inbox item `ask` raised. 404 when the id isn't an escalation, 409 when it is handled already or older than 14 days.
|
|
82
361
|
- `GET /fleet`: the workers in flight, with session activity, waits and spend (today, total).
|
|
83
362
|
- `POST /workers` with `{ "item": "ATK-309", "project"?, "probe"?, "prompt"?, "conductor"? }`: launch; answers 202 with the `launching` worker. `GET /workers/:id` shows it with its latest events.
|
|
84
363
|
- `DELETE /workers/:id` (`?force=true`): retire; 409 with the unfinished work when it refuses.
|
|
85
364
|
- `POST /workers/:id/attach`: wake if parked, mark attached, open the tab.
|
|
365
|
+
- `POST /items/:id/notes` with `{ "body": "## work-issue · …", "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"`.
|
|
366
|
+
- `GET /inbox`: the open inbox items, oldest first.
|
|
367
|
+
- `POST /inbox/:id/resolve`: Jason's answer (see Inbox). The body is `{ "action": "approve", "text"? }`, `{ "action": "revise", "text" }` or `{ "action": "done" }`.
|
|
368
|
+
- 200 with the item, the comment and the waits it resolved.
|
|
369
|
+
- 404 when there is no such item.
|
|
370
|
+
- 409 when the item is resolved already or isn't a plan.
|
|
371
|
+
- 502 with `posted` when the tracker didn't take the comment.
|
|
372
|
+
- `GET /notify`: the profile (and until when), the profiles, quiet hours, the held count and each channel's state.
|
|
373
|
+
- `POST /notify/profile` with `{ "name": "away", "until"?: "<ISO time>" }`: switch; 409 for a profile that doesn't exist.
|
|
374
|
+
- `POST /notify/test` with `{ "kind"?: "npm-otp", "channel"?: "pushover", "raise"?: false }`: push a sample now and answer each channel's result, or with `raise` put a real item in the inbox (201).
|
|
375
|
+
- `POST /notify/report` with `{ "channel": "remote-control", "result": "<PushNotification's answer>", "state"?: "ok" | "inactive" }`: the mod's report of a push.
|
|
376
|
+
- `GET /rules`: the rules in force, each with its source, `ref`, `when`, `do` and the rules that replace it, plus any file refused and the live `overrides` (see Rules).
|
|
377
|
+
- `GET /queue`: the scheduler's last plan; `?fresh=true` reads the trackers and plans now (see Scheduler).
|
|
378
|
+
- `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`.
|
|
379
|
+
- `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.
|
|
380
|
+
- `GET /why/:id`: the event's chain of causes and the rule behind it; `GET /why?item=ATK-315` the same for the item's newest actions.
|
|
381
|
+
- `POST /rules/test` with `{ "event"?: "pr.comment", "project"?: "atk", "fields"?: { … } }`: a dry run; 400 for a sample it can't read.
|
|
382
|
+
- `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.
|
|
383
|
+
- `GET /locks`: the locks held. `POST /locks/:name` with `{ "action": "acquire" | "release" | "status", "owner"? }`: 200 with the work-issue helper's lock JSON; 409 for an acquire or release without an owner.
|
|
86
384
|
- `POST /waits` with `{ "worker": 1, "condition": "pr:ado:Emergent.AgenticToolkit#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.
|
|
87
385
|
- A refusal (a duplicate launch, a bad condition, a busy worker) is a 409 with `error` and `details`.
|
|
88
386
|
|
|
@@ -96,7 +394,20 @@ conduct install-startup
|
|
|
96
394
|
conduct project add <path to the project's main checkout>
|
|
97
395
|
```
|
|
98
396
|
|
|
99
|
-
`conduct project add` walks up from the path it is given to the directory that holds `.claude/conductor.json`, and maps a worktree to its main checkout.
|
|
397
|
+
`conduct project add` walks up from the path it is given to the directory that holds `.claude/conductor.json`, and maps a worktree to its main checkout. The daemon reloads a profile's `rules` when the file changes; every other change, such as a new `skills.work`, takes effect at the next `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. The same holds for the copy the daemon stored at `conduct project add`: after an `--allow-downgrade` to a version without those fields, the project shows `the stored profile no longer validates` and isn't conducted until a profile without them is added again.
|
|
398
|
+
|
|
399
|
+
The profile's optional `work` section is for the project's work skill; the daemon only checks it:
|
|
400
|
+
|
|
401
|
+
```jsonc
|
|
402
|
+
"work": {
|
|
403
|
+
// Changes under these paths, and plans of these size tiers, land in review mode.
|
|
404
|
+
"landing": { "review": ["infra/", "pipelines/"], "reviewTiers": ["L"] },
|
|
405
|
+
// The pipelines that stage an npm package for a merge, by the key the work skill reports them under.
|
|
406
|
+
"releases": [{ "key": "cli", "pipeline": "pipelines/release-cli.yml", "package": "@detergent-software/atk" }],
|
|
407
|
+
// The project's own steps, which the work skill follows at its hook points.
|
|
408
|
+
"hooks": ".claude/work-hooks.md"
|
|
409
|
+
}
|
|
410
|
+
```
|
|
100
411
|
|
|
101
412
|
The logon task runs `node` on the `cli.js` that ran `install-startup`. A global install keeps that path across upgrades, so the task needs registering once. To upgrade:
|
|
102
413
|
|
|
@@ -104,7 +415,7 @@ The logon task runs `node` on the `cli.js` that ran `install-startup`. A global
|
|
|
104
415
|
conduct upgrade
|
|
105
416
|
```
|
|
106
417
|
|
|
107
|
-
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. Otherwise it stops the daemon, runs `npm install --global @detergent-software/conductor@<tag>` and starts the daemon again: through the `Conductor` logon task when the task runs this `cli.js`, else as a detached `conduct serve`. 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`.
|
|
418
|
+
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 `Conductor` logon task when the task runs this `cli.js`, else as a detached `conduct serve`. 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`.
|
|
108
419
|
|
|
109
420
|
**From a checkout instead (development).** `npm link` puts the main checkout's build on PATH, so every worktree and the logon task run that build. Run it from the main checkout, and upgrade by pulling `develop`, building, then `conduct stop` and `conduct serve`:
|
|
110
421
|
|
|
@@ -117,6 +428,43 @@ conduct install-startup
|
|
|
117
428
|
|
|
118
429
|
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 task points at the installed `cli.js`.
|
|
119
430
|
|
|
431
|
+
## Console
|
|
432
|
+
|
|
433
|
+
The console (design note, section 11) is the one Claude session that stays open beside the daemon. The conductor mod in it does the plumbing in code (see Mod). Its model wakes only for an escalation or for you, and runs the project's console skill (`/conductor-console`, installed by ATK). It has no loop: no Monitor, no heartbeat, no ticks. Everything it knows comes from the daemon, so a new console after a close loses nothing.
|
|
434
|
+
|
|
435
|
+
```bash
|
|
436
|
+
conduct console # a tab in Windows Terminal (window "conductor"), in the project's main checkout
|
|
437
|
+
conduct console --here # the same session in this terminal
|
|
438
|
+
conduct console --project cadence # with several projects registered: whose checkout it runs in
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
It runs `claude -n conductor --permission-mode auto -- /conductor-console` with `CONDUCTOR_CONSOLE=1`. The skill starts the daemon if it isn't running (`conduct start`, detached, so it doesn't end with the session), then handles what waits.
|
|
442
|
+
|
|
443
|
+
**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.
|
|
444
|
+
|
|
445
|
+
**Moving from conduct-atk.** Stop the conduct-atk session, `conduct upgrade` (the scheduler now launches by default), `atk install` in the main checkout, then `conduct console`. A machine that keeps conduct-atk's loop sets `"scheduler": { "mode": "observe" }` in `config.json`.
|
|
446
|
+
|
|
447
|
+
## Mod
|
|
448
|
+
|
|
449
|
+
`mod/` is the conductor mod: a Claude Code plugin (a hooks module, code that costs no tokens) that does the console's plumbing. `conduct install-mod` installs it for this user from the package's own `mod/` folder, as a folder marketplace (`claude plugin install conductor --marketplace <package>/mod --scope user`). Claude Code reads a folder marketplace's plugin in place, so `conduct upgrade` replaces the mod along with the daemon; open sessions load the new version with `/reload-plugins`. `--remove` uninstalls it.
|
|
450
|
+
|
|
451
|
+
- **Every session** shows a status line, `conductor 3▶ 2⏸ ✉2` (running, parked, inbox), from `GET /fleet` and `GET /inbox` every 30 s. It clears while no daemon answers.
|
|
452
|
+
- **The console**, the session started with `CONDUCTOR_CONSOLE=1` (`conduct console` starts it; see Console), also gets:
|
|
453
|
+
- **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;
|
|
454
|
+
- **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), the workers by project (each project's rule has [pause] and [focus]; a row expands to attach and why), and today's spend;
|
|
455
|
+
- **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);
|
|
456
|
+
- **the stream**: it runs `node <package>/build/cli.js events --follow`, redraws on each event, toasts each new inbox item, and acts on three events:
|
|
457
|
+
|
|
458
|
+
| Event | Data | The mod |
|
|
459
|
+
|---|---|---|
|
|
460
|
+
| `notify.requested` | `channel: "remote-control"`, `message` | pushes it to the phone (PushNotification), with no model turn, and reports every outcome to `POST /notify/report` (see Notifications). When the push comes back "Remote Control inactive" (PushNotification's `disabledReason` `no_transport`), mobile pushes are off in Claude Code's config (`config_off`), or the call fails, it toasts once, saying which; it says so again only after a push has reached the phone. A push Claude Code holds back while you are at the terminal (`user_present`) is reported as `skipped`, with no toast |
|
|
461
|
+
| `console.escalated` | `text` | wakes the console's model once with `[conductor] escalation #<event id> on <item>: <text>`; nothing else wakes it |
|
|
462
|
+
| `worker.message-requested` | `to`, `message` | sends the message to that worker's session |
|
|
463
|
+
|
|
464
|
+
The notifier emits `notify.requested` (see Notifications), and the rules' `escalate console` action emits `console.escalated`. Nothing emits `worker.message-requested` yet.
|
|
465
|
+
|
|
466
|
+
Its checks: `pnpm -C apps/conductor test` runs `mod/tests/model.spec.ts` (the logic, in CI). `claude plugin validate apps/conductor/mod` and `claude plugin test apps/conductor/mod` (the hooks against the engine, UI on the terminal, desktop, VS Code and mobile surfaces) need Claude Code, so they run locally. To try a change before installing it: `claude --plugin-dir apps/conductor/mod`.
|
|
467
|
+
|
|
120
468
|
## Releases
|
|
121
469
|
|
|
122
470
|
`atk-conductor-release` (`pipelines/release-conductor.yml`) runs semantic-release on each push to `develop` or `main` that touches `apps/conductor/`. It counts only commits that touch this directory, writes `CHANGELOG.md`, tags `conductor-v<version>` (`X.Y.Z-dev.N` on `develop`), and stages the version on npm under `dev` or `latest`. A maintainer publishes it with the `npm stage approve <id> --otp=<code>` line from the run summary.
|
|
@@ -129,4 +477,6 @@ pnpm -C apps/conductor typecheck
|
|
|
129
477
|
pnpm -C apps/conductor test
|
|
130
478
|
pnpm -C apps/conductor build
|
|
131
479
|
cd apps/conductor && node scripts/check-changelog.mjs
|
|
480
|
+
claude plugin validate apps/conductor/mod
|
|
481
|
+
claude plugin test apps/conductor/mod
|
|
132
482
|
```
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { lockVerdict } from "./broker.js";
|
|
4
|
+
import { callApi, NotRunningError } from "./client.js";
|
|
5
|
+
import { ConductorError } from "./errors.js";
|
|
6
|
+
import { lockSchema, portAssignmentSchema } from "./store.js";
|
|
7
|
+
import { daemonWorker, gitToplevel } from "./wait.js";
|
|
8
|
+
// Exit code when no daemon brokered the request: none runs, an older one has no broker, or it
|
|
9
|
+
// refused (a checkout in no registered project, no free slot). The caller keeps its own scheme,
|
|
10
|
+
// as ATK's scripts/dev-ports.mjs and the work-issue helper's file lock do.
|
|
11
|
+
export const NOT_BROKERED = 3;
|
|
12
|
+
// How often `conduct lock acquire --wait` asks again, as the work-issue helper's lock does.
|
|
13
|
+
const LOCK_POLL_MS = 15_000;
|
|
14
|
+
const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
|
|
15
|
+
const lockAnswer = z.looseObject({ acquired: z.boolean().optional(), lock: z.string() });
|
|
16
|
+
// The broker's locks for `conduct status`, or null from a daemon older than the broker.
|
|
17
|
+
export async function heldLocks(paths, now = new Date()) {
|
|
18
|
+
const answer = await callApi(paths, { method: 'GET', path: '/locks' });
|
|
19
|
+
if (answer.status === 404)
|
|
20
|
+
return null;
|
|
21
|
+
if (answer.status !== 200) {
|
|
22
|
+
const refusal = errorBody.safeParse(answer.body).data;
|
|
23
|
+
throw new ConductorError(refusal?.error ?? `the API answered ${answer.status} for the locks`, refusal?.details ?? []);
|
|
24
|
+
}
|
|
25
|
+
return z
|
|
26
|
+
.object({ locks: z.array(lockSchema) })
|
|
27
|
+
.parse(answer.body)
|
|
28
|
+
.locks.map((lock) => ({ ...lock, verdict: lockVerdict(lock, null, now) === 'stale' ? 'stale' : 'held' }));
|
|
29
|
+
}
|
|
30
|
+
// `conduct lock <acquire|release|status> <name>`: the broker's named lock, such as chrome, which
|
|
31
|
+
// verify skills of every project take turns with. Prints the work-issue helper's JSON. The owner
|
|
32
|
+
// is --owner, else the item of the worker whose worktree this is.
|
|
33
|
+
export async function lockCommand(paths, action, name, options, io) {
|
|
34
|
+
if (action !== 'acquire' && action !== 'release' && action !== 'status')
|
|
35
|
+
throw new ConductorError('usage: conduct lock <acquire|release|status> <name> [--owner <id>] [--wait <minutes>]');
|
|
36
|
+
if (!/^[\w-]+$/.test(name))
|
|
37
|
+
throw new ConductorError(`a lock name is letters, digits, _ and -, not "${name}"`);
|
|
38
|
+
let owner = options.owner?.trim() || null;
|
|
39
|
+
if (owner === null) {
|
|
40
|
+
const worker = await daemonWorker(paths, await gitToplevel(io.cwd), undefined);
|
|
41
|
+
if (worker === 'no-daemon')
|
|
42
|
+
return notBrokered(io, 'the conductor is not running');
|
|
43
|
+
if (worker === 'unreachable')
|
|
44
|
+
throw new ConductorError("the conductor didn't answer");
|
|
45
|
+
owner = worker?.item ?? null;
|
|
46
|
+
if (owner === null && action !== 'status')
|
|
47
|
+
throw new ConductorError('no worker of the conductor works in this checkout: pass --owner <id>');
|
|
48
|
+
}
|
|
49
|
+
const until = Date.now() + (options.waitMinutes ?? 0) * 60_000;
|
|
50
|
+
const sleep = io.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
51
|
+
for (;;) {
|
|
52
|
+
const answer = await ask(paths, { body: { action, ...(owner === null ? {} : { owner }) }, method: 'POST', path: `/locks/${name}` });
|
|
53
|
+
if (!answer.ok)
|
|
54
|
+
return notBrokered(io, answer.why);
|
|
55
|
+
const body = lockAnswer.parse(answer.body);
|
|
56
|
+
if (action !== 'acquire' || body.acquired !== false || Date.now() >= until) {
|
|
57
|
+
io.out(JSON.stringify(body, null, 2));
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
await sleep(LOCK_POLL_MS);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// One line per lock: name, holder, and its last acquire.
|
|
64
|
+
export function lockLine(lock) {
|
|
65
|
+
return ` ${lock.name} ${lock.holder} since ${new Date(lock.since).toLocaleString()}${lock.verdict === 'stale' ? ' (stale: free to take over)' : ''}`;
|
|
66
|
+
}
|
|
67
|
+
// `conduct ports [<path>]`: the checkout's dev ports from the broker, or every assignment.
|
|
68
|
+
export async function portsCommand(paths, path, options, io) {
|
|
69
|
+
if (path === undefined) {
|
|
70
|
+
const answer = await ask(paths, { method: 'GET', path: '/ports' });
|
|
71
|
+
if (!answer.ok)
|
|
72
|
+
return notBrokered(io, answer.why);
|
|
73
|
+
const assignments = z.object({ assignments: z.array(portAssignmentSchema) }).parse(answer.body).assignments;
|
|
74
|
+
if (options.json)
|
|
75
|
+
io.out(JSON.stringify(assignments, null, 2));
|
|
76
|
+
else if (assignments.length === 0)
|
|
77
|
+
io.out('no worktree has ports from the conductor');
|
|
78
|
+
else
|
|
79
|
+
assignments.forEach((a) => io.out(portsLine(a)));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
const answer = await ask(paths, {
|
|
83
|
+
body: { worktree: resolve(io.cwd, path), ...(options.prefer === undefined ? {} : { prefer: options.prefer }) },
|
|
84
|
+
method: 'POST',
|
|
85
|
+
path: '/ports',
|
|
86
|
+
});
|
|
87
|
+
if (!answer.ok)
|
|
88
|
+
return notBrokered(io, answer.why);
|
|
89
|
+
const { assignment } = z.object({ assignment: portAssignmentSchema }).parse(answer.body);
|
|
90
|
+
io.out(options.json ? JSON.stringify(assignment, null, 2) : portsLine(assignment));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
// One line per checkout: project, slot, each port, then where.
|
|
94
|
+
export function portsLine(a) {
|
|
95
|
+
const ports = Object.entries(a.ports)
|
|
96
|
+
.map(([name, port]) => `${name} ${port}`)
|
|
97
|
+
.join(' · ');
|
|
98
|
+
return `${a.project} slot ${a.slot} ${ports} ${a.worktree}`;
|
|
99
|
+
}
|
|
100
|
+
// The body of a 200 answer, or why nothing was brokered.
|
|
101
|
+
async function ask(paths, request) {
|
|
102
|
+
let answer;
|
|
103
|
+
try {
|
|
104
|
+
answer = await callApi(paths, request);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof NotRunningError)
|
|
108
|
+
return { ok: false, why: 'the conductor is not running' };
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (answer.status === 200)
|
|
112
|
+
return { body: answer.body, ok: true };
|
|
113
|
+
// A daemon from before the broker has no such route.
|
|
114
|
+
if (answer.status === 404)
|
|
115
|
+
return { ok: false, why: 'this conductor has no port broker (upgrade it)' };
|
|
116
|
+
const refusal = errorBody.safeParse(answer.body).data;
|
|
117
|
+
if (answer.status === 409 && refusal)
|
|
118
|
+
return { ok: false, why: [refusal.error, ...(refusal.details ?? [])].join('; ') };
|
|
119
|
+
throw new ConductorError(refusal?.error ?? `the API answered ${answer.status}`, refusal?.details ?? []);
|
|
120
|
+
}
|
|
121
|
+
function notBrokered(io, why) {
|
|
122
|
+
io.err(`not brokered: ${why}`);
|
|
123
|
+
return NOT_BROKERED;
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=broker-cli.js.map
|