@detergent-software/conductor 3.6.0-dev.5 → 3.6.0-dev.6

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,13 @@
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.6.0-dev.6](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.6.0-dev.5&targetVersion=GTconductor-v3.6.0-dev.6) (2026-11-07)
6
+
7
+ ### Bug Fixes
8
+
9
+ * **conductor:** address review of the console's skill check (ATK-941) ([80e5ce2](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/80e5ce2da137517b3007f8a63108bf9bbd44bac0))
10
+ * **conductor:** read the console's skill from what the model is sent (ATK-941) ([c451be1](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/c451be1b348c4f0bd51f1c771be78dceaa4e048e))
11
+
5
12
  ## [3.6.0-dev.5](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v3.6.0-dev.4&targetVersion=GTconductor-v3.6.0-dev.5) (2026-11-07)
6
13
 
7
14
  ### Bug Fixes
package/README.md CHANGED
@@ -974,7 +974,7 @@ The mod is the plugin's hooks module, code that costs no tokens, which does the
974
974
  - **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;
975
975
  - **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;
976
976
  - **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);
977
- - **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;
977
+ - **the skill, loaded in code** (ATK-385, ATK-840, ATK-843, ATK-941): a console whose conversation lacks `/conductor:conductor-console` (one started by hand, as `claude --plugin-dir … -n conductor`, or after a `/clear` or a resume) 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. After a compaction or a resume, the next prompt or escalation reads the conversation as the model is sent it (`$.session.messages({ as: 'api' })`) for the skill's first line of prose, and loads nothing when it is there. A compaction raises no band warning: the mod can't tell until that read. The engine puts a skill that ran back after a compaction's boundary as a meta message, which the compaction's own rows leave out. After a compaction, the debug log reads `conductor: compacted; the next prompt checks for /conductor:conductor-console`, then `conductor: the compaction kept|dropped /conductor:conductor-console`. A slash command loads nothing (`/conductor:conductor-console` expands the skill with its own prompt, which carries nothing): 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;
978
978
  - **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;
979
979
  - **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:
980
980
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detergent-software/conductor",
3
- "version": "3.6.0-dev.5",
3
+ "version": "3.6.0-dev.6",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor",
3
- "version": "3.6.0-dev.5",
3
+ "version": "3.6.0-dev.6",
4
4
  "description": "The conductor's plugin, loaded only into the sessions it starts: the mod (the console's band, pane and commands, and each session's report to the daemon), the console skill, the reference work skill and the verification-skill pair.",
5
5
  "author": {
6
6
  "name": "Detergent Software"
@@ -90,11 +90,14 @@ const confirming = atom({ key: 'confirming', plugin: 'conductor' } as const, nul
90
90
  const expanded = atom({ key: 'expanded', plugin: 'conductor' } as const, null);
91
91
  const hasSkill = atom({ key: 'hasSkill', plugin: 'conductor' } as const, false);
92
92
  const cleared = atom({ key: 'cleared', plugin: 'conductor' } as const, null);
93
+ const compacted = atom({ key: 'compacted', plugin: 'conductor' } as const, false);
93
94
 
94
95
  // The console is the session started with CONDUCTOR_CONSOLE=1 (`conduct console` sets it): only it
95
96
  // draws the band and the pane, answers the commands, follows the stream and acts on events. Both
96
97
  // flags start over with the module, and session.start sets the first again after a reload.
97
98
  let isConsole = false;
99
+ // The read consoleHasSkill has in flight (ATK-941).
100
+ let skillCheck: null | Promise<boolean> = null;
98
101
  let isRefreshing = false;
99
102
  // The plugin folder's conductor.json, read once per load (daemon).
100
103
  let named: null | Promise<ReturnType<typeof daemonOf>> = null;
@@ -235,6 +238,21 @@ async function confirm($: EngineInterface, id: number): Promise<void> {
235
238
  $.clock.after(CONFIRM_MS, () => void update($, confirming, (staged) => (staged === id ? null : staged)));
236
239
  }
237
240
 
241
+ // ATK-941: whether the console's conversation holds its skill, as the model is sent it. Taken on
242
+ // trust while the skill is loaded and nothing compacted since; otherwise read from
243
+ // $.session.messages({ as: 'api' }), as a worker's is (ATK-937). The rows leave out meta messages,
244
+ // which is where a skill's expansion is and where the engine puts it back after a compaction, and
245
+ // a resumed conversation may hold it already. An empty conversation (a /clear, a start) holds
246
+ // nothing. A skill file that can't be read counts as gone, so the prompt asks the model to load it.
247
+ // A prompt and an escalation at once share one read, so they don't both load the skill.
248
+ async function consoleHasSkill($: EngineInterface): Promise<boolean> {
249
+ if (!(await read($, compacted)) && (await read($, hasSkill))) return true;
250
+ skillCheck ??= readConsoleSkill($).finally(() => {
251
+ skillCheck = null;
252
+ });
253
+ return skillCheck;
254
+ }
255
+
238
256
  async function copy(
239
257
  $: EngineInterface,
240
258
  text: string,
@@ -355,6 +373,7 @@ async function loadSkill($: EngineInterface): Promise<string> {
355
373
  async function markSkillLoaded($: EngineInterface): Promise<void> {
356
374
  await update($, hasSkill, () => true);
357
375
  await update($, cleared, () => null);
376
+ await update($, compacted, () => false);
358
377
  }
359
378
 
360
379
  // The mod's version, read again on each refresh (ATK-882): a refresh of the daemon's copy that
@@ -431,7 +450,7 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
431
450
  }
432
451
  case 'escalate':
433
452
  // The one place the mod wakes the console's model: facts only, as the rules engine wrote them.
434
- if (await read($, hasSkill)) await $.prompt.submit({ text: act.text });
453
+ if (await consoleHasSkill($)) await $.prompt.submit({ text: act.text });
435
454
  else await escalateWithSkill($, act.text);
436
455
  return;
437
456
  case 'ignore':
@@ -530,6 +549,47 @@ async function push($: EngineInterface, message: string): Promise<void> {
530
549
  }
531
550
  }
532
551
 
552
+ // consoleHasSkill's read. A conversation that can't be read, or a skill whose first line of prose is
553
+ // too short to tell apart (keepsSkill), leaves the state as it was.
554
+ async function readConsoleSkill($: EngineInterface): Promise<boolean> {
555
+ const isCompacted = await read($, compacted);
556
+ let messages: unknown;
557
+ try {
558
+ messages = await $.session.messages({ as: 'api' });
559
+ } catch (error) {
560
+ $.ui.log(
561
+ `conductor: the conversation can't be read (${error instanceof Error ? error.message : String(error)}); /${CONSOLE_SKILL} not checked`,
562
+ { to: 'debug' },
563
+ );
564
+ return read($, hasSkill);
565
+ }
566
+ let isHeld = false;
567
+ let unread = '';
568
+ if (Array.isArray(messages) && messages.length > 0) {
569
+ let body: null | string = null;
570
+ try {
571
+ body = await loadSkill($);
572
+ } catch (error) {
573
+ unread = ` (its file can't be read: ${error instanceof Error ? error.message : String(error)})`;
574
+ }
575
+ if (body !== null && !keepsSkill([body], body)) {
576
+ $.ui.log(`conductor: /${CONSOLE_SKILL} has no first line of prose to tell it by; not checked`, { to: 'debug' });
577
+ await update($, compacted, () => false);
578
+ return read($, hasSkill);
579
+ }
580
+ if (body !== null) isHeld = keepsSkill(apiTexts(messages), body);
581
+ }
582
+ if (isCompacted) {
583
+ $.ui.log(`conductor: the compaction ${isHeld ? 'kept' : 'dropped'} /${CONSOLE_SKILL}${unread}`, { to: 'debug' });
584
+ }
585
+ if (isHeld) await markSkillLoaded($);
586
+ else {
587
+ await update($, hasSkill, () => false);
588
+ await update($, compacted, () => false);
589
+ }
590
+ return isHeld;
591
+ }
592
+
533
593
  // Reads /fleet, /inbox and /notify, and the mod's own version, into the state the drawings read.
534
594
  async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
535
595
  const readAt = await $.clock.now();
@@ -781,38 +841,27 @@ export const register: Register = (on) => {
781
841
  disarmIdle();
782
842
  warms = 0;
783
843
  await update($, hasSkill, () => false);
844
+ await update($, compacted, () => false);
784
845
  if (!isClearing) await update($, cleared, () => null);
785
846
  }
786
847
  return next(e);
787
848
  });
788
849
 
789
850
  // ATK-843: a compaction ends no conversation, but it can drop the skill: the mod's copy is a block
790
- // beside one prompt, which a summary only paraphrases. What the compaction kept says which, and the
791
- // next prompt loads the skill again when it went, as after a /clear. A skill file that can't be
792
- // read counts as gone, so that prompt asks the model to load it.
851
+ // beside one prompt, which a summary only paraphrases. The engine puts a skill that ran back after
852
+ // the boundary as a meta message, which the compaction's rows leave out (ATK-941), and a read of
853
+ // the conversation here still shows the old one (ATK-937), so the next prompt or escalation reads
854
+ // what the model is sent (consoleHasSkill) and loads the skill again only when it went.
793
855
  // Whether the skill was there is read before the compaction runs: an escalation meanwhile marks it
794
856
  // loaded for a run that comes after, which the compaction can't hold yet.
795
857
  on('session.compact', async ($, e, next) => {
796
858
  const isWatched = isConsole && e.agentId === undefined && e.trigger !== 'precompute';
797
859
  const hadSkill = isWatched && (await read($, hasSkill));
798
- const compacted = await next(e);
799
- if (!hadSkill || compacted.skip !== undefined) return compacted;
800
- // The skill's text in a message, or in a tool's result when the Skill tool loaded it.
801
- const texts = compacted.messages.flatMap((message) => [
802
- message.text,
803
- ...message.toolUses.map((use) => use.text ?? ''),
804
- ...(message.toolResults ?? []).map((result) => result.text),
805
- ]);
806
- let isKept = false;
807
- let unread = '';
808
- try {
809
- isKept = keepsSkill(texts, await loadSkill($));
810
- } catch (error) {
811
- unread = ` (its file can't be read: ${error instanceof Error ? error.message : String(error)})`;
812
- }
813
- $.ui.log(`conductor: the compaction ${isKept ? 'kept' : 'dropped'} /${CONSOLE_SKILL}${unread}`, { to: 'debug' });
814
- if (!isKept) await update($, hasSkill, () => false);
815
- return compacted;
860
+ const result = await next(e);
861
+ if (!hadSkill || result.skip !== undefined) return result;
862
+ await update($, compacted, () => true);
863
+ $.ui.log(`conductor: compacted; the next prompt checks for /${CONSOLE_SKILL}`, { to: 'debug' });
864
+ return result;
816
865
  });
817
866
 
818
867
  // ATK-386: each of the console's own turns starts its idle point over, and a turn in progress
@@ -841,14 +890,15 @@ export const register: Register = (on) => {
841
890
  // ATK-840: the first prompt of a console without its skill (the operator's or an escalation's)
842
891
  // carries the skill, loaded by the mod, so its rules hold without the model choosing to load it;
843
892
  // it counts once the prompt has entered. A slash command loads nothing: the skill's own expands
844
- // before it, and the rest carry the line asking the model to load it, as does every prompt while
845
- // the skill's file can't be read.
893
+ // with it, inside next (skill.prompt, ATK-941), and the rest carry the line asking the model to
894
+ // load it, as does every prompt while the skill's file can't be read.
846
895
  on('prompt.submit', async ($, e, next) => {
847
896
  if (!isConsole) {
848
897
  const context = await workerContext($, e);
849
898
  return next(context === null ? e : { ...e, context: [...(e.context ?? []), context] });
850
899
  }
851
- if (await read($, hasSkill)) return next(e);
900
+ if (e.command?.name === CONSOLE_SKILL) return next(e);
901
+ if (await consoleHasSkill($)) return next(e);
852
902
  const ask = (): ReturnType<typeof next> => next({ ...e, context: [...(e.context ?? []), NO_SKILL_CONTEXT] });
853
903
  if (e.command !== undefined) return ask();
854
904
  let loaded: string;
@@ -66,6 +66,9 @@ declare module 'claude-code' {
66
66
  // Why the mod last cleared the idle console (ATK-386), until /conductor-console runs again;
67
67
  // null when it didn't. The band says so in place of the no-skill warning.
68
68
  cleared: null | string;
69
+ // Whether a compaction ran since the console last had its skill (ATK-941): the next prompt or
70
+ // escalation reads the conversation to tell whether it still holds the skill.
71
+ compacted: boolean;
69
72
  // The inbox id whose approve waits on a second press in the band.
70
73
  confirming: null | number;
71
74
  // The worker whose row the pane shows expanded, by name.