@detergent-software/conductor 3.5.0-dev.1 → 3.5.0-dev.3

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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  Releases are tagged `conductor-v<version>` and staged on npm by `atk-conductor-release` (`pipelines/release-conductor.yml`), which writes each section below. `scripts/check-changelog.mjs` fails when a tag has no section here.
4
4
 
5
+ ## [3.5.0-dev.3](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.5.0-dev.2&targetVersion=GTconductor-v3.5.0-dev.3) (2026-10-29)
6
+
7
+ ### Bug Fixes
8
+
9
+ * **conductor:** load the console skill again after a compaction (ATK-843) ([793a837](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/793a8377407f7a18c5fdffa09b9a9fd41ccafd00))
10
+ * **conductor:** read the skill's state before the compaction (ATK-843) ([95a10d7](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/95a10d79f462f136fb173cb31f71ba2623ae4bd1))
11
+
12
+ ## [3.5.0-dev.2](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.5.0-dev.1&targetVersion=GTconductor-v3.5.0-dev.2) (2026-10-29)
13
+
14
+ ### Features
15
+
16
+ * **conductor:** load the console skill in code after a /clear (ATK-840) ([6b13e41](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/6b13e41d3b47946d12a8503f7d6512ee2b3e8f8a))
17
+
18
+ ### Bug Fixes
19
+
20
+ * **conductor:** mark the console skill loaded only when it is (ATK-840) ([0c8aa3a](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/0c8aa3a02fe55416776456d81dc2b3c6b3b760a8))
21
+
5
22
  ## [3.5.0-dev.1](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.4.0&targetVersion=GTconductor-v3.5.0-dev.1) (2026-10-27)
6
23
 
7
24
  ### Features
package/README.md CHANGED
@@ -955,8 +955,8 @@ The mod is the plugin's hooks module, code that costs no tokens, which does the
955
955
  - **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;
956
956
  - **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, answer on a `scope`, `blocked`, `human` or `operator` Handoff, or a `failed` one whose worker waits on its item, which fills `/answer #<id> ` in the prompt, 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;
957
957
  - **commands answered in code**, never by the model: `/conductor`, `/inbox`, `/approve <item | #id>`, `/revise <item | #id> <text>`, `/answer <item | #id> <text>` (`conduct done #<id> --answer <text>`, the text as typed), `/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);
958
- - **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;
959
- - **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;
958
+ - **the skill, loaded in code** (ATK-385, ATK-840, ATK-843): a console whose conversation lacks `/conductor:conductor-console` (one started by hand, as `claude --plugin-dir … -n conductor`, after a `/clear` or a resume, or after a compaction that kept only a summary of the skill: the mod checks what the compaction kept for the skill's first line of prose, and writes `conductor: the compaction kept|dropped /conductor:conductor-console` to the debug log) shows `console without its skill: /conductor:conductor-console loads with the next prompt` in the band, and the mod loads the skill itself with the next prompt, not the model. The operator's prompt carries the skill's text (its `SKILL.md` without the frontmatter, read from the plugin folder) as context the model reads first, with a line to run its Start, then answer. An escalation runs `/conductor:conductor-console` with the escalation as its arguments, since the mod's own `prompt.submit` hook never sees a prompt the mod submits. Nothing runs at the clear itself. A slash command loads nothing (`/conductor:conductor-console` loads itself): it carries a line asking the model to load the skill, as before, and so does every prompt while the file can't be read, or an escalation whose run the engine refuses;
959
+ - **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, when the mod loads the skill (above). 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;
960
960
  - **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:
961
961
 
962
962
  | Event | Data | The mod |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detergent-software/conductor",
3
- "version": "3.5.0-dev.1",
3
+ "version": "3.5.0-dev.3",
4
4
  "description": "Conductor: the local daemon that runs conducted projects' work loops (conduct serve) and its conduct CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,11 +48,38 @@ export function reportedState(outcome: PushOutcome): 'failing' | 'inactive' | 'o
48
48
 
49
49
  // The skill a console runs first (ATK-385), as this plugin namespaces it: the engine names a plugin
50
50
  // skill `<plugin>:<skill>` in skill.prompt (ATK-525). `conduct console` starts with it as the first
51
- // prompt; a console started by hand (`claude --plugin-dir … -n conductor`) has to be told.
51
+ // prompt; in a console started by hand (`claude --plugin-dir … -n conductor`), or after a /clear or
52
+ // a resume, the mod loads it with the next prompt (ATK-840).
52
53
  export const CONSOLE_SKILL = 'conductor:conductor-console';
53
- // What the model reads beside each prompt of a console whose skill hasn't run.
54
+ // The skill's file, under the plugin folder ($.plugin.root).
55
+ export const CONSOLE_SKILL_FILE = 'skills/conductor-console/SKILL.md';
56
+ // What the model reads beside a prompt when the mod couldn't read the skill to load it.
54
57
  export const NO_SKILL_CONTEXT = `This session is the conductor console (CONDUCTOR_CONSOLE=1), but /${CONSOLE_SKILL} hasn't run in it, so its rules don't hold. Load the ${CONSOLE_SKILL} skill with the Skill tool and follow it, then answer this prompt.`;
55
- export const NO_SKILL_WARNING = `console without its skill: run /${CONSOLE_SKILL}`;
58
+ export const NO_SKILL_WARNING = `console without its skill: /${CONSOLE_SKILL} loads with the next prompt`;
59
+
60
+ // ATK-843: whether a conversation's texts still hold the skill after a compaction. The mark is the
61
+ // body's first line of prose, cut short: a summary paraphrases it, while it may repeat the heading.
62
+ // A first line too short to tell apart (a fence, a rule) marks nothing, so the skill counts as gone.
63
+ export function keepsSkill(texts: readonly string[], body: string): boolean {
64
+ const prose = body
65
+ .split(/\r?\n/)
66
+ .map((line) => line.trim())
67
+ .find((line) => line !== '' && !line.startsWith('#'));
68
+ const mark = prose?.slice(0, 120);
69
+ return mark !== undefined && mark.length >= 40 && texts.some((text) => text.includes(mark));
70
+ }
71
+
72
+ // What the model reads beside the first prompt of a console without its skill: the skill, as the
73
+ // mod loaded it (ATK-840). It comes after the prompt, so it says to run Start first.
74
+ export function loadedSkillContext(body: string): string {
75
+ return `This session is the conductor console (CONDUCTOR_CONSOLE=1), and this conversation started without /${CONSOLE_SKILL} (a /clear, a resume, or a console started by hand), so the conductor mod loaded it: the skill follows. Follow it from its Start, then answer the prompt above.\n\n${body}`;
76
+ }
77
+
78
+ // A SKILL.md without its frontmatter: the text the engine expands for the model.
79
+ export function skillBody(file: string): string {
80
+ const head = /^\uFEFF?---\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n|$)/.exec(file);
81
+ return (head === null ? file : file.slice(head[0].length)).trim();
82
+ }
56
83
 
57
84
  // ATK-386: the console idles for hours, and its prompt cache lives an hour past its last use, so a
58
85
  // wake after that wrote its whole context again (89k in ATK-365's run). A fork reads that context
@@ -13,6 +13,7 @@ import {
13
13
  bandEntries,
14
14
  clearedText,
15
15
  CONSOLE_SKILL,
16
+ CONSOLE_SKILL_FILE,
16
17
  daemonOf,
17
18
  endpointOf,
18
19
  findHandoff,
@@ -24,6 +25,8 @@ import {
24
25
  idleAction,
25
26
  inboxText,
26
27
  isWebLink,
28
+ keepsSkill,
29
+ loadedSkillContext,
27
30
  MAYBE_POSTED,
28
31
  NO_SKILL_CONTEXT,
29
32
  NO_SKILL_WARNING,
@@ -43,6 +46,7 @@ import {
43
46
  reportedState,
44
47
  reviseText,
45
48
  runText,
49
+ skillBody,
46
50
  snapshotOf,
47
51
  spendToday,
48
52
  statusText,
@@ -275,6 +279,31 @@ async function endpoint($: EngineInterface): Promise<Endpoint> {
275
279
  return found;
276
280
  }
277
281
 
282
+ // ATK-840: an escalation to a console without its skill runs the skill, with the escalation as its
283
+ // arguments: one turn, the skill as the engine expands it. The mod's prompt.submit hook can't load it
284
+ // there, as the engine never runs a hook on what its own plugin submits (the re-entry rule). The
285
+ // console counts as loaded from here, so a prompt or escalation meanwhile doesn't load it twice, and
286
+ // the stream reads on rather than wait out the skill's turn. Should the run be refused, it isn't
287
+ // loaded after all, and the escalation goes as it is, asking the model to load the skill.
288
+ async function escalateWithSkill($: EngineInterface, text: string): Promise<void> {
289
+ await markSkillLoaded($);
290
+ void $.command.run({ args: text, command: CONSOLE_SKILL }).catch(async (error: unknown) => {
291
+ await update($, hasSkill, () => false);
292
+ $.ui.log(
293
+ `conductor: /${CONSOLE_SKILL} not run (${error instanceof Error ? error.message : String(error)}); asking the model to load it`,
294
+ { to: 'debug' },
295
+ );
296
+ try {
297
+ await $.prompt.submit({ text: `${text}\n\n${NO_SKILL_CONTEXT}` });
298
+ } catch (submitError) {
299
+ $.ui.log(
300
+ `conductor: escalate failed: ${submitError instanceof Error ? submitError.message : String(submitError)}`,
301
+ { to: 'debug' },
302
+ );
303
+ }
304
+ });
305
+ }
306
+
278
307
  // `conduct events --follow` from this package's own build, so the mod and the CLI are one version
279
308
  // and no .cmd shim is in the way. It reconnects by itself; a child that ends is started again after
280
309
  // the last event it showed, so nothing in between is lost. One that keeps exiting at once (no
@@ -318,6 +347,18 @@ async function follow($: EngineInterface): Promise<void> {
318
347
  $.ui.log('conductor: conduct events keeps exiting; the console polls instead', { to: 'debug' });
319
348
  }
320
349
 
350
+ // The console skill as the engine would expand it: its file's text without the frontmatter. A mod's
351
+ // `$` has no `$.skill` to run it through the skill.prompt hooks (engine 2.1.312), so none sees it.
352
+ async function loadSkill($: EngineInterface): Promise<string> {
353
+ return skillBody(await $.fs.read(`${$.plugin.root}/${CONSOLE_SKILL_FILE}`));
354
+ }
355
+
356
+ // The console's conversation has its skill: the band's warning and its "cleared while idle" go.
357
+ async function markSkillLoaded($: EngineInterface): Promise<void> {
358
+ await update($, hasSkill, () => true);
359
+ await update($, cleared, () => null);
360
+ }
361
+
321
362
  // The console has been idle WARM_AFTER_MS: keep its cache warm with a ping, or clear it (ATK-386).
322
363
  // Either is told to the daemon, which logs it as a `console.cache` event: a fork is no turn of the
323
364
  // transcript, so the event is the only place its cost shows.
@@ -384,7 +425,8 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
384
425
  }
385
426
  case 'escalate':
386
427
  // The one place the mod wakes the console's model: facts only, as the rules engine wrote them.
387
- await $.prompt.submit({ text: act.text });
428
+ if (await read($, hasSkill)) await $.prompt.submit({ text: act.text });
429
+ else await escalateWithSkill($, act.text);
388
430
  return;
389
431
  case 'ignore':
390
432
  return;
@@ -667,16 +709,12 @@ export const register: Register = (on) => {
667
709
  return started;
668
710
  });
669
711
 
670
- // A console the skill never ran in (one started by hand as `claude -n conductor`, ATK-385) is
671
- // told to load it before it answers, each prompt until it has. `/conductor:conductor-console` or the Skill
672
- // tool expands it before its prompt is submitted; a /clear or a resume starts a conversation
673
- // without it.
712
+ // Whether the console's conversation has its skill (ATK-385): `/conductor:conductor-console` or
713
+ // the Skill tool expands it before its prompt is submitted, and prompt.submit loads it when
714
+ // neither has (ATK-840). A /clear or a resume starts a conversation without it.
674
715
  on('skill.prompt', { skill: CONSOLE_SKILL }, async ($, e, next) => {
675
716
  const prompt = await next(e);
676
- if (isConsole) {
677
- await update($, hasSkill, () => true);
678
- await update($, cleared, () => null);
679
- }
717
+ if (isConsole) await markSkillLoaded($);
680
718
  return prompt;
681
719
  });
682
720
 
@@ -692,6 +730,35 @@ export const register: Register = (on) => {
692
730
  return next(e);
693
731
  });
694
732
 
733
+ // ATK-843: a compaction ends no conversation, but it can drop the skill: the mod's copy is a block
734
+ // beside one prompt, which a summary only paraphrases. What the compaction kept says which, and the
735
+ // next prompt loads the skill again when it went, as after a /clear. A skill file that can't be
736
+ // read counts as gone, so that prompt asks the model to load it.
737
+ // Whether the skill was there is read before the compaction runs: an escalation meanwhile marks it
738
+ // loaded for a run that comes after, which the compaction can't hold yet.
739
+ on('session.compact', async ($, e, next) => {
740
+ const isWatched = isConsole && e.agentId === undefined && e.trigger !== 'precompute';
741
+ const hadSkill = isWatched && (await read($, hasSkill));
742
+ const compacted = await next(e);
743
+ if (!hadSkill || compacted.skip !== undefined) return compacted;
744
+ // The skill's text in a message, or in a tool's result when the Skill tool loaded it.
745
+ const texts = compacted.messages.flatMap((message) => [
746
+ message.text,
747
+ ...message.toolUses.map((use) => use.text ?? ''),
748
+ ...(message.toolResults ?? []).map((result) => result.text),
749
+ ]);
750
+ let isKept = false;
751
+ let unread = '';
752
+ try {
753
+ isKept = keepsSkill(texts, await loadSkill($));
754
+ } catch (error) {
755
+ unread = ` (its file can't be read: ${error instanceof Error ? error.message : String(error)})`;
756
+ }
757
+ $.ui.log(`conductor: the compaction ${isKept ? 'kept' : 'dropped'} /${CONSOLE_SKILL}${unread}`, { to: 'debug' });
758
+ if (!isKept) await update($, hasSkill, () => false);
759
+ return compacted;
760
+ });
761
+
695
762
  // ATK-386: each of the console's own turns starts its idle point over, and a turn in progress
696
763
  // holds it off. A subagent's turn raises no turn.start and its turn.complete carries its agentId;
697
764
  // a ping's, should the engine raise one, carries the ping's prompt.
@@ -715,10 +782,28 @@ export const register: Register = (on) => {
715
782
  return done;
716
783
  });
717
784
 
785
+ // ATK-840: the first prompt of a console without its skill (the operator's or an escalation's)
786
+ // carries the skill, loaded by the mod, so its rules hold without the model choosing to load it;
787
+ // it counts once the prompt has entered. A slash command loads nothing: the skill's own expands
788
+ // before it, and the rest carry the line asking the model to load it, as does every prompt while
789
+ // the skill's file can't be read.
718
790
  on('prompt.submit', async ($, e, next) => {
719
791
  if (!isConsole || (await read($, hasSkill))) return next(e);
720
- $.ui.log(`conductor: /${CONSOLE_SKILL} hasn't run; asking the model to load it`, { to: 'debug' });
721
- return next({ ...e, context: [...(e.context ?? []), NO_SKILL_CONTEXT] });
792
+ const ask = (): ReturnType<typeof next> => next({ ...e, context: [...(e.context ?? []), NO_SKILL_CONTEXT] });
793
+ if (e.command !== undefined) return ask();
794
+ let loaded: string;
795
+ try {
796
+ loaded = await loadSkill($);
797
+ } catch (error) {
798
+ $.ui.log(
799
+ `conductor: /${CONSOLE_SKILL} not loaded (${error instanceof Error ? error.message : String(error)}); asking the model to load it`,
800
+ { to: 'debug' },
801
+ );
802
+ return ask();
803
+ }
804
+ const entered = await next({ ...e, context: [...(e.context ?? []), loadedSkillContext(loaded)] });
805
+ if (entered.drop === undefined) await markSkillLoaded($);
806
+ return entered;
722
807
  });
723
808
 
724
809
  on('command.run', { command: 'conductor' }, async ($) => {
@@ -794,8 +879,8 @@ export const register: Register = (on) => {
794
879
 
795
880
  // The band above the prompt, in the console, while the inbox has items. A bare digit typed into
796
881
  // an empty prompt presses a band Button, so a plan's approve takes a second press (`y`). Until
797
- // /conductor:conductor-console runs, a warning sits above it, or above the engine's own (ATK-385); after the
798
- // mod cleared an idle console, a plain line that says so instead (ATK-386).
882
+ // the console has its skill, a warning sits above it, or above the engine's own (ATK-385); after
883
+ // the mod cleared an idle console, a plain line that says so instead (ATK-386).
799
884
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
800
885
  if (!isConsole || e.props.hasSurvey) return next(e);
801
886
  const { Box, Button, Text } = $.ui.resolve(e);
@@ -32,7 +32,7 @@ When `/conductor:conductor-console` runs:
32
32
  2. Check the setup in one Bash call: `echo $CONDUCTOR_CONSOLE` and `conduct --version`.
33
33
  - Not `1`: say that the band, the pane and the escalation wake-ups are off in this session, and that `conduct console` opens a proper one. Go on with chat.
34
34
  - If the operator says the band or pane is missing: the mod comes with the conductor plugin in `~/.conductor/plugin`, which only `conduct console` loads (`--plugin-dir`). Check that the folder holds `hooks/register.tsx`, then run `/reload-plugins`; if it doesn't, `conduct start` (a daemon refreshes it as it starts), then close this session and run `conduct console`.
35
- 3. `conduct escalations`. These are escalations raised while no console was open. Handle each one, oldest first, as below.
35
+ 3. `conduct escalations`. These are escalations raised while no console was open. Handle each one, oldest first, as below. When the skill ran with an escalation as its arguments (`[conductor] escalation #<id> …`: the mod woke a console that had lost its skill after a `/clear` or a resume), handle that one here too, once, whether or not the list has it.
36
36
  4. Report in two or three lines from `conduct status`, `conduct inbox` and the first line of `conduct queue`: workers running and parked, what waits on the operator, and the scheduler's mode. If the mode is `off`, say that the daemon launches nothing until `"scheduler": { "mode": "on" }` is set in `~/.conductor/config.json` and the daemon is restarted (`conduct stop`, then `conduct start`).
37
37
 
38
38
  Then end the turn.