Orchestrator sessions: bind to .remember/<slug>/README.md, keep the role in the system prompt, and at a context limit ask for a handoff, then compact when the…

hooks/register.ts 1430 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 PluginOptions,
5 Register,
6 SessionCompactInput,
7 SessionMeasureInput,
8 Timer,
9} from 'claude-code'
10
11import type { OrchBinding, OrchDoneRecord, OrchOpenRecord, OrchPhase, OrchSequence } from '../types'
12import { FRONTMATTER_KEYS, GOAL_TODO, isNewSlug, isSlug, parseProject, scaffoldOf, slugsIn } from './project'
13import { fillRole } from './role'
14
15const SECTION_ID = 'orch:role'
16const HANDOFF_MARK = '[orch] Update the handoff now'
17const OWN_MARK = 'The handoff file was updated before this compaction.'
18const TOOL_NAME = 'handoff_done'
19const TOOL = 'mcp__orch__handoff_done'
20const TOOL_TEXT =
21 'Signals to the orch mod that the handoff of an orchestrator project is written. Call it once, only after the handoff for that project is written to its files. `slug` and `code` are the ones the handoff request of the orch mod gives. The orchestrator session then compacts between turns.'
22/** What a call in another session sends to the orchestrator's session; its `session.receive` hook takes it. */
23const RELAY_MARK = '[orch-handoff-done]'
24/** The backup signal: a file beside the README whose content holds the open code. */
25const MARKER_FILE = '.handoff'
26const SETTLE_MS = 500
27/** How often an open sequence looks for the backup signals (the marker file, a relay record) with no turn end. */
28const POLL_MS = 15 * 1000
29const SLOW_TICK_MS = 60 * 1000
30/** How long the handoff turn has to start and to end. */
31const SEQUENCE_TIMEOUT_MS = 30 * 60 * 1000
32/** How long /compact has to come to the front of the queue after it was asked for. */
33const COMPACT_WAIT_MS = 30 * 60 * 1000
34const STOP_TOAST_MS = 20 * 1000
35const FRESH_WINDOW_MS = 10 * 60 * 1000
36const CODE_LETTERS = 'abcdefghjkmnpqrstuvwxyz23456789'
37const USAGE = 'usage: /orch <slug> | /orch new <slug> [goal] | /orch | /orch off | /orch handoff | /orch done'
38
39type Mode = 'auto' | 'ask' | 'off'
40
41type Config = {
42 root: string
43 limitPercent: number
44 warnPercent: number
45 limitTokens: number
46 mode: Mode
47 readmeMaxLines: number
48 /** How long a sequence waits for its signal after the handoff turn. */
49 waitMs: number
50}
51
52const IDLE: OrchSequence = {
53 phase: 'idle',
54 startedAt: 0,
55 turnId: '',
56 phaseAt: 0,
57 askedAt: 0,
58 lastEnd: '',
59 lastEndAt: 0,
60 isArmed: true,
61 hasWarned: false,
62 hasNagged: false,
63 code: '',
64 usedCode: '',
65 signal: '',
66}
67
68const SEQUENCE = { plugin: 'orch', key: 'sequence' } as const
69
70const binding = atom({ plugin: 'orch', key: 'binding' } as const, null)
71const sequence = atom(SEQUENCE, IDLE)
72
73let isInteractive = true
74/** The sequence as this module last read or wrote it: `$.state` holds the same value for a reload. */
75let held: OrchSequence | null = null
76/** The binding as this module last read or wrote it; undefined before the first read of a load. */
77let boundNow: OrchBinding | null | undefined
78let tick: Timer | null = null
79let tickMs = 0
80let isStepping = false
81/** The backup signals that were read and did not count, each logged once for a sequence. */
82let ignored = new Set<string>()
83/** How the /compact this module asked for ended, from the moment it ran until the sequence settles on it. */
84let ownOutcome: { isDone: boolean; reason: string } | null = null
85
86let config: Config = {
87 root: '.remember',
88 limitPercent: 55,
89 warnPercent: 45,
90 limitTokens: 0,
91 mode: 'auto',
92 readmeMaxLines: 300,
93 waitMs: 10 * 60 * 1000,
94}
95
96function numberOf(value: unknown, fallback: number): number {
97 return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
98}
99
100function configOf(options: PluginOptions): Config {
101 const root = typeof options.rememberRoot === 'string' ? options.rememberRoot : ''
102 const mode = options.mode
103
104 return {
105 root: root.replace(/^\.\/|\/+$/g, '') || '.remember',
106 limitPercent: numberOf(options.limitPercent, 55) || 55,
107 warnPercent: numberOf(options.warnPercent, 45),
108 limitTokens: numberOf(options.limitTokens, 0),
109 mode: mode === 'ask' || mode === 'off' ? mode : 'auto',
110 readmeMaxLines: numberOf(options.readmeMaxLines, 300),
111 waitMs: (numberOf(options.handoffWaitMinutes, 10) || 10) * 60 * 1000,
112 }
113}
114
115function messageOf(error: unknown): string {
116 return error instanceof Error ? error.message : String(error)
117}
118
119function roleOf(b: OrchBinding): string {
120 return fillRole({
121 slug: b.slug,
122 goal: b.goal,
123 readme: b.readmeRel,
124 dir: `${b.root}/${b.slug}`,
125 root: b.root,
126 linear_project: b.linearProject,
127 canonical: b.canonical,
128 limit: config.limitPercent,
129 role: b.role,
130 })
131}
132
133/**
134 * The role as a message of the conversation. The engine renders the system
135 * prompt at the first request and again after a compaction, so a session
136 * bound between the two reads its role here until the next compaction.
137 */
138function roleMessageOf(b: OrchBinding): string {
139 return `The orch mod bound this session to orchestrator project ${b.slug}. After the next compaction the system prompt holds the section below; until then this message is that section.\n\n${roleOf(b)}`
140}
141
142function markerOf(b: OrchBinding): string {
143 return b.readme.replace(/README\.md$/, MARKER_FILE)
144}
145
146function markerRelOf(b: OrchBinding): string {
147 return b.readmeRel.replace(/README\.md$/, MARKER_FILE)
148}
149
150function minutesOf(ms: number): string {
151 return String(Math.round((ms / 60000) * 100) / 100)
152}
153
154function handoffPromptOf(b: OrchBinding, code: string): string {
155 return [
156 `${HANDOFF_MARK}: in ${b.readme} (and the state files it points to) write current state, open lanes, pending decisions, restart steps. Do not start new work.`,
157 `When the handoff is written, call the tool ${TOOL} with slug "${b.slug}" and code "${code}". That call is the signal: the orch mod then compacts this session between turns. Nothing else starts the compaction.`,
158 `If a scribe or a lane writes the handoff, give it the slug and the code: it makes the call itself after the write.`,
159 `Where the tool is not available, write the code into the file ${markerOf(b)} (its content is the code).`,
160 `Then stop. The wait for the signal ends ${minutesOf(config.waitMs)} minutes after this turn.`,
161 ].join(' ')
162}
163
164function signalHelpOf(b: OrchBinding, code: string): string {
165 return `signal: call ${TOOL} with slug "${b.slug}" and code "${code}", or write the code to ${markerRelOf(b)}, or run /orch done`
166}
167
168function newCode(): string {
169 const letters = [...crypto.getRandomValues(new Uint8Array(8))].map(
170 byte => CODE_LETTERS[byte % CODE_LETTERS.length] ?? 'x',
171 )
172
173 return `${letters.slice(0, 4).join('')}-${letters.slice(4).join('')}`
174}
175
176function openKeyOf(slug: string): string {
177 return `open/${slug}`
178}
179
180function doneKeyOf(slug: string): string {
181 return `done/${slug}`
182}
183
184function isOpenRecord(value: unknown): value is OrchOpenRecord {
185 const record = value as Partial<OrchOpenRecord> | null | undefined
186
187 return typeof record?.code === 'string' && typeof record.sessionId === 'string'
188}
189
190function isDoneRecord(value: unknown): value is OrchDoneRecord {
191 const record = value as Partial<OrchDoneRecord> | null | undefined
192
193 return typeof record?.code === 'string'
194}
195
196function restartPromptOf(b: OrchBinding): string {
197 return `Read ${b.readme} and continue as orchestrator for ${b.slug}.`
198}
199
200function projectLineOf(b: OrchBinding): string {
201 return `Orchestrator for ${b.slug}: ${b.goal}. Keep: open lanes, pending decisions, in-flight PRs and tickets, restart steps. Entry point ${b.readmeRel}.`
202}
203
204function describe(b: OrchBinding): string {
205 const lines = [
206 `bound to ${b.slug} (${b.readmeRel}, ${b.lines} lines)`,
207 `goal: ${b.goal}`,
208 `linear: ${b.linearProject === '' ? 'not set' : b.linearProject}`,
209 ]
210
211 if (b.canonical.length > 0) {
212 lines.push(`canonical: ${b.canonical.join(' | ')}`)
213 }
214
215 lines.push(
216 `handoff signal: the tool ${TOOL} with the code of the handoff request (backup: the code in ${markerRelOf(b)}, or /orch done). Compaction follows the signal.`,
217 )
218
219 if (!b.hasFrontmatter) {
220 lines.push(
221 `frontmatter missing: the goal is the first heading. Add these keys between two --- lines at the top of the README: ${FRONTMATTER_KEYS.join(', ')}.`,
222 )
223 }
224
225 if (b.lines > config.readmeMaxLines) {
226 lines.push(
227 `warning: the README has ${b.lines} lines (limit ${config.readmeMaxLines}). It is the entry point: move state to the files it points to.`,
228 )
229 }
230
231 return lines.join('\n')
232}
233
234function note($: EngineInterface, text: string): void {
235 $.ui.log(text)
236}
237
238function showStatus($: EngineInterface, b: OrchBinding | null, percent: number | undefined): void {
239 $.ui.status(
240 b === null ? undefined : `${b.slug} ${percent === undefined ? '-' : percent}/${config.limitPercent}%`,
241 )
242}
243
244async function percentNow($: EngineInterface): Promise<number | undefined> {
245 try {
246 return (await $.session.usage()).context.percent
247 } catch (error) {
248 note($, `the context fill is not available: ${messageOf(error)}`)
249
250 return undefined
251 }
252}
253
254async function bindingNow($: EngineInterface): Promise<OrchBinding | null> {
255 if (boundNow === undefined) {
256 const stored = await read($, binding)
257
258 boundNow ??= stored
259 }
260
261 return boundNow
262}
263
264async function sequenceNow($: EngineInterface): Promise<OrchSequence> {
265 if (held === null) {
266 const stored = await read($, sequence)
267
268 held ??= { ...IDLE, ...stored }
269 }
270
271 return held
272}
273
274async function patch($: EngineInterface, change: Partial<OrchSequence>): Promise<OrchSequence> {
275 const next: OrchSequence = { ...(held ?? (await sequenceNow($))), ...change }
276
277 held = next
278 await $.state.set(SEQUENCE, next)
279
280 return next
281}
282
283function isOpen(phase: OrchPhase): boolean {
284 return phase !== 'idle'
285}
286
287function cancelTick(): void {
288 tick?.cancel()
289 tick = null
290}
291
292function tickMsOf(state: OrchSequence): number {
293 if (state.phase === 'compacting') {
294 // Signaled and not asked for yet (a load of the module came between): ask soon.
295 return state.askedAt === 0 ? SETTLE_MS : SLOW_TICK_MS
296 }
297
298 return POLL_MS
299}
300
301/**
302 * One timer while a sequence is open. A turn end drives the sequence in a busy
303 * session; the timer drives it in a quiet one, and applies each time limit.
304 */
305function armTick($: EngineInterface, state: OrchSequence): void {
306 if (!isOpen(state.phase)) {
307 return
308 }
309
310 const ms = tickMsOf(state)
311
312 if (tick !== null && tickMs <= ms) {
313 return
314 }
315
316 cancelTick()
317 tickMs = ms
318 tick = $.clock.after(ms, () => {
319 tick = null
320 void step($)
321 })
322}
323
324function agoOf(ms: number): string {
325 const seconds = Math.max(0, Math.round(ms / 1000))
326
327 return seconds < 120 ? `${seconds} s` : `${Math.round(seconds / 60)} min`
328}
329
330function describeSequence(state: OrchSequence, now: number, b: OrchBinding | null): string[] {
331 const lines: string[] = []
332
333 if (state.phase === 'idle') {
334 lines.push(
335 `sequence: idle${state.isArmed ? '' : ' (not armed: the fill did not drop below the warning level after the last sequence)'}`,
336 )
337 } else if (state.phase === 'requested') {
338 lines.push(`sequence: handoff prompt sent ${agoOf(now - state.startedAt)} ago; its turn has not started`)
339 } else if (state.phase === 'running') {
340 lines.push(`sequence: the handoff turn is running (requested ${agoOf(now - state.startedAt)} ago)`)
341 } else if (state.phase === 'waiting') {
342 lines.push(
343 `sequence: waiting for the handoff signal (${agoOf(now - state.phaseAt)} of ${minutesOf(config.waitMs)} min). Compaction follows the signal.`,
344 )
345 } else {
346 lines.push(
347 state.askedAt > 0
348 ? `sequence: handoff signaled (${state.signal}); /compact is in the queue (asked ${agoOf(now - state.askedAt)} ago; it runs between turns, and the wait ends after ${COMPACT_WAIT_MS / 60000} min)`
349 : `sequence: handoff signaled (${state.signal}); /compact is not asked for yet`,
350 )
351 }
352
353 if (b !== null && state.phase !== 'idle' && state.phase !== 'compacting') {
354 lines.push(`code: ${state.code}`, signalHelpOf(b, state.code))
355 }
356
357 if (state.lastEnd !== '') {
358 lines.push(`last sequence: ${state.lastEnd} (${agoOf(now - state.lastEndAt)} ago)`)
359 }
360
361 return lines
362}
363
364async function slugsOf($: EngineInterface): Promise<string[]> {
365 const root = `${await $.session.cwd()}/${config.root}`
366
367 if (!(await $.fs.exists(root))) {
368 return []
369 }
370
371 const entries = await $.fs.list(root)
372 const slugs: string[] = []
373
374 for (const entry of entries) {
375 if (entry.kind === 'dir' && isSlug(entry.name) && (await $.fs.exists(`${root}/${entry.name}/README.md`))) {
376 slugs.push(entry.name)
377 }
378 }
379
380 return slugs.sort()
381}
382
383/** A bind or an unbind starts from an idle sequence; one that was open is stopped, and says so. */
384async function dropSequence($: EngineInterface, why: string): Promise<void> {
385 const state = await sequenceNow($)
386
387 if (isOpen(state.phase)) {
388 await stop($, `${why} in phase ${state.phase}`, 'No compaction.')
389 }
390
391 const { lastEnd, lastEndAt } = await sequenceNow($)
392
393 await patch($, { ...IDLE, lastEnd, lastEndAt })
394}
395
396async function bind($: EngineInterface, slug: string): Promise<OrchBinding | string> {
397 if (!isSlug(slug)) {
398 return `"${slug}" is not a project name. ${USAGE}`
399 }
400
401 const readmeRel = `${config.root}/${slug}/README.md`
402 const readme = `${await $.session.cwd()}/${readmeRel}`
403
404 if (!(await $.fs.exists(readme))) {
405 const hint = isNewSlug(slug) ? `: create it with /orch new ${slug}` : ''
406
407 return `${readmeRel} does not exist${hint}. projects: ${(await slugsOf($)).join(', ') || 'none'}`
408 }
409
410 const project = parseProject(await $.fs.read(readme), slug)
411 const bound: OrchBinding = { slug, readme, readmeRel, root: config.root, ...project }
412
413 await dropSequence($, `bound to ${slug}`)
414 boundNow = bound
415 await update($, binding, () => bound)
416 showStatus($, bound, await percentNow($))
417 note($, `bound to ${slug} (${readmeRel})`)
418
419 return bound
420}
421
422async function unbind($: EngineInterface): Promise<void> {
423 await dropSequence($, 'unbound')
424 boundNow = null
425 await update($, binding, () => null)
426 showStatus($, null, undefined)
427 note($, 'unbound')
428}
429
430/**
431 * Binds from the person's own words: one project README named, and the word
432 * orchestrator beside it. Any other text binds nothing.
433 */
434async function autoBind($: EngineInterface, text: string): Promise<OrchBinding | null> {
435 if (!/\borchestrat/i.test(text)) {
436 return null
437 }
438
439 const slugs = slugsIn(text, config.root)
440 const slug = slugs[0]
441
442 if (slugs.length !== 1 || slug === undefined) {
443 return null
444 }
445
446 const bound = await bind($, slug)
447
448 if (typeof bound === 'string') {
449 $.ui.log(bound, { to: 'debug' })
450
451 return null
452 }
453
454 $.ui.toast(`bound to ${slug} from your prompt. /orch off unbinds.`)
455
456 return bound
457}
458
459/** Removes what the open sequence left in `$.store` for the sessions of the lanes. */
460async function withdraw($: EngineInterface, slug: string, keys: readonly string[]): Promise<void> {
461 try {
462 for (const key of keys) {
463 await $.store.delete(key)
464 }
465 } catch (error) {
466 note($, `the store records of ${slug} were not removed: ${messageOf(error)}`)
467 }
468}
469
470/**
471 * Ends the open sequence. With `endedAt` given, the phase is `idle` before the
472 * first await: what comes next to end the same sequence finds none open.
473 */
474async function endSequence($: EngineInterface, reason: string, endedAt?: number): Promise<void> {
475 cancelTick()
476
477 const at = endedAt ?? (await $.clock.now())
478
479 await patch($, {
480 phase: 'idle',
481 turnId: '',
482 phaseAt: 0,
483 askedAt: 0,
484 code: '',
485 signal: '',
486 lastEnd: reason,
487 lastEndAt: at,
488 })
489 note($, `sequence ended: ${reason}`)
490
491 const slug = boundNow?.slug
492
493 if (slug !== undefined) {
494 await withdraw($, slug, [openKeyOf(slug), doneKeyOf(slug)])
495 }
496}
497
498/** Ends a sequence that did not compact. The person reads why in a toast and in the log. */
499async function stop($: EngineInterface, reason: string, advice: string, endedAt?: number): Promise<void> {
500 await endSequence($, reason, endedAt)
501 $.ui.toast(`handoff sequence stopped: ${reason}. ${advice}`, { timeoutMs: STOP_TOAST_MS })
502}
503
504async function submitHandoff($: EngineInterface): Promise<void> {
505 try {
506 const b = await bindingNow($)
507
508 if (b === null) {
509 await stop($, 'unbound before the handoff prompt', 'No compaction.')
510
511 return
512 }
513
514 const entered = await $.prompt.submit({ text: handoffPromptOf(b, (await sequenceNow($)).code) })
515
516 if (entered.drop !== undefined) {
517 await stop($, `the handoff prompt did not enter: ${entered.drop}`, 'Run /orch handoff.')
518 }
519 } catch (error) {
520 await stop($, `the handoff prompt failed: ${messageOf(error)}`, 'Run /orch handoff.')
521 }
522}
523
524/**
525 * Starts one handoff sequence unless one is in flight; the prompt is submitted
526 * after the dispatch, as a prompt starts only once the session is idle.
527 */
528async function requestHandoff($: EngineInterface, why: string): Promise<boolean> {
529 const startedAt = await $.clock.now()
530
531 if (isOpen((await sequenceNow($)).phase)) {
532 return false
533 }
534
535 const code = newCode()
536 const state = await patch($, {
537 phase: 'requested',
538 startedAt,
539 turnId: '',
540 phaseAt: startedAt,
541 askedAt: 0,
542 isArmed: false,
543 code,
544 signal: '',
545 })
546
547 ignored = new Set()
548 note($, `handoff requested (${why}); code ${code}`)
549
550 const slug = boundNow?.slug
551
552 if (slug !== undefined) {
553 // What a handoff_done call in another session reads: the code, and where this session is.
554 try {
555 await $.store.delete(doneKeyOf(slug))
556 const record: OrchOpenRecord = { code, sessionId: await $.session.id(), at: startedAt }
557
558 await $.store.set(openKeyOf(slug), record)
559 } catch (error) {
560 note($, `the open record of ${slug} was not stored (a call in another session cannot find this one): ${messageOf(error)}`)
561 }
562 }
563
564 $.clock.after(0, () => {
565 void submitHandoff($)
566 })
567 armTick($, state)
568
569 return true
570}
571
572/** A compaction stood: the sequence ends and the restart prompt goes in. */
573async function complete($: EngineInterface, b: OrchBinding, how: string, endedAt: number): Promise<void> {
574 await endSequence($, how, endedAt)
575
576 // Not awaited: the prompt's own turn starts when the session is free.
577 void $.prompt.submit({ text: restartPromptOf(b) }).then(
578 entered => {
579 if (entered.drop === undefined) {
580 note($, 'restart prompt submitted')
581 } else {
582 note($, `restart prompt dropped: ${entered.drop}`)
583 $.ui.toast(`compacted, but the restart prompt did not enter: ${entered.drop}`, { timeoutMs: STOP_TOAST_MS })
584 }
585 },
586 (error: unknown) => {
587 note($, `restart prompt failed: ${messageOf(error)}`)
588 $.ui.toast(`compacted, but the restart prompt failed: ${messageOf(error)}`, { timeoutMs: STOP_TOAST_MS })
589 },
590 )
591}
592
593/**
594 * The /compact this module asked for has run, or its call failed: the sequence
595 * ends on how it went. The compaction hook and the command's own answer both
596 * come here, and a module loaded after the request comes through the hook
597 * alone; the first one to come settles, the next finds nothing to do.
598 */
599async function settle($: EngineInterface, fallback: string): Promise<void> {
600 try {
601 const at = await $.clock.now()
602 const b = await bindingNow($)
603 const state = held ?? (await sequenceNow($))
604
605 if (state.phase !== 'compacting' || state.askedAt === 0) {
606 return
607 }
608
609 const outcome = ownOutcome
610
611 ownOutcome = null
612
613 // No await from the check above to the end of the sequence: the second report finds it idle.
614 if (outcome?.isDone === true && b !== null) {
615 await complete($, b, `compacted (${outcome.reason})`, at)
616 } else {
617 await stop($, `no compaction: ${outcome?.reason ?? fallback}`, 'Run /compact.', at)
618 }
619 } catch (error) {
620 note($, `the sequence did not settle: ${messageOf(error)}`)
621 }
622}
623
624/**
625 * Asks for the compaction with the /compact command.
626 *
627 * The engine queues the command behind the prompts that wait and runs it
628 * between two turns, so a busy session compacts too. `$.session.compact` is
629 * not used: live, with a prompt in the queue, the engine starts that prompt a
630 * few milliseconds after a turn ends and then refuses the call ("a turn is in
631 * flight"), at each turn end, for as long as the session stays busy.
632 */
633async function ask($: EngineInterface): Promise<void> {
634 const at = await $.clock.now()
635 const saved = patch($, { phase: 'compacting', phaseAt: at, askedAt: at })
636
637 ownOutcome = null
638 void $.command.run({ command: 'compact', args: OWN_MARK }).then(
639 () => settle($, '/compact ran and did not compact'),
640 (error: unknown) => settle($, `/compact failed: ${messageOf(error)}`),
641 )
642 await saved
643 note($, 'handoff signaled; /compact asked for. It runs between turns, after the prompts that wait in the queue.')
644}
645
646type Verdict = 'ok' | 'used' | 'wrong' | 'closed'
647
648/**
649 * Takes one signal for the open sequence. The check and the change of phase
650 * are one synchronous step, so two signals that come together count once.
651 */
652async function signal($: EngineInterface, code: string, how: string, detail: string): Promise<Verdict> {
653 const state = held ?? (await sequenceNow($))
654
655 if (state.phase === 'idle') {
656 return code !== '' && code === state.usedCode ? 'used' : 'closed'
657 }
658
659 if (state.phase === 'compacting') {
660 return code === state.usedCode ? 'used' : 'wrong'
661 }
662
663 if (code !== state.code) {
664 return 'wrong'
665 }
666
667 // No await between the check above and this change of `held`.
668 const saved = patch($, { phase: 'compacting', askedAt: 0, signal: how, usedCode: code })
669
670 await saved
671 await patch($, { phaseAt: await $.clock.now() })
672 note($, `handoff signal received (${how})${detail === '' ? '' : `: ${detail}`}`)
673
674 const slug = boundNow?.slug
675
676 if (slug !== undefined) {
677 // A later call in another session reads "no open sequence".
678 await withdraw($, slug, [openKeyOf(slug)])
679 }
680
681 return 'ok'
682}
683
684function noteOnce($: EngineInterface, key: string, text: string): void {
685 if (!ignored.has(key)) {
686 ignored.add(key)
687 note($, text)
688 }
689}
690
691/**
692 * The backup signals of an open sequence: the marker file beside the README,
693 * and the record a `handoff_done` call in another session left in `$.store`.
694 * Only the open code counts; a file time counts for nothing.
695 */
696async function backupOf($: EngineInterface, b: OrchBinding, state: OrchSequence): Promise<string | null> {
697 try {
698 const marker = markerOf(b)
699
700 if (await $.fs.exists(marker)) {
701 const text = (await $.fs.read(marker)).trim().toLowerCase()
702
703 if (text.includes(state.code)) {
704 return `marker file ${markerRelOf(b)}`
705 }
706
707 noteOnce(
708 $,
709 `marker:${text}`,
710 `${markerRelOf(b)} ${text === '' ? 'is empty' : 'holds a wrong or an old code'}: ignored. The open code is ${state.code}.`,
711 )
712 }
713 } catch (error) {
714 noteOnce($, 'marker:error', `${markerRelOf(b)} could not be read: ${messageOf(error)}`)
715 }
716
717 try {
718 const done = await $.store.get(doneKeyOf(b.slug))
719
720 if (isDoneRecord(done)) {
721 if (done.code === state.code) {
722 return `relay record from session ${done.from}`
723 }
724
725 noteOnce($, `done:${done.code}`, `the relay record of ${b.slug} holds a wrong or an old code: ignored.`)
726 }
727 } catch (error) {
728 noteOnce($, 'done:error', `the relay record of ${b.slug} could not be read: ${messageOf(error)}`)
729 }
730
731 return null
732}
733
734/**
735 * Moves an open sequence one step: looks for a backup signal, applies the time
736 * limits, and asks for the compaction once the signal is in.
737 */
738async function advance($: EngineInterface): Promise<void> {
739 const state = await sequenceNow($)
740
741 if (!isOpen(state.phase)) {
742 return
743 }
744
745 const b = await bindingNow($)
746
747 if (b === null) {
748 await stop($, `the session is not bound (phase ${state.phase})`, 'No compaction.')
749
750 return
751 }
752
753 if (state.phase === 'compacting') {
754 if (state.askedAt === 0) {
755 await ask($)
756 } else if ((await $.clock.now()) - state.askedAt > COMPACT_WAIT_MS) {
757 // The command is in the queue, asked for by this module or by the one a
758 // reload replaced. It is never asked for twice.
759 await stop(
760 $,
761 `no compaction: /compact did not run in ${COMPACT_WAIT_MS / 60000} minutes after it was asked for`,
762 'Run /compact.',
763 )
764 }
765
766 return
767 }
768
769 const how = await backupOf($, b, state)
770 const now = await $.clock.now()
771
772 // A signal that came while this step read the backups has moved the sequence on.
773 if (held !== null && (held.phase !== state.phase || held.code !== state.code)) {
774 return
775 }
776
777 if (how !== null) {
778 if ((await signal($, state.code, how, '')) === 'ok') {
779 await ask($)
780 }
781
782 return
783 }
784
785 if (state.phase === 'waiting') {
786 if (now - state.phaseAt > config.waitMs) {
787 await stop(
788 $,
789 `no handoff signal: ${TOOL_NAME} was not called with code ${state.code} in ${minutesOf(config.waitMs)} minutes after the handoff turn`,
790 'No compaction. Run /orch handoff when the handoff is written.',
791 )
792 }
793
794 return
795 }
796
797 if (now - state.startedAt > SEQUENCE_TIMEOUT_MS) {
798 await stop(
799 $,
800 `timed out: the handoff turn did not ${state.phase === 'requested' ? 'start' : 'end'} in ${SEQUENCE_TIMEOUT_MS / 60000} minutes`,
801 'Run /orch handoff.',
802 )
803 }
804}
805
806/** One step at a time; each call leaves the timer armed while the sequence is open. */
807async function step($: EngineInterface): Promise<void> {
808 if (!isStepping) {
809 isStepping = true
810
811 try {
812 await advance($)
813 } catch (error) {
814 await stop($, `failed: ${messageOf(error)}`, 'Run /orch handoff.').catch((later: unknown) => {
815 note($, `the sequence state did not reset: ${messageOf(later)}`)
816 })
817 } finally {
818 isStepping = false
819 }
820 }
821
822 // A sequence that took its signal while a step ran is asked for on the short timer this arms.
823
824 try {
825 armTick($, await sequenceNow($))
826 } catch (error) {
827 note($, `the sequence timer did not start: ${messageOf(error)}`)
828 }
829}
830
831/** A step after the dispatch that took a signal: /compact cannot be asked for inside a hook a turn waits on. */
832function stepSoon($: EngineInterface): void {
833 $.clock.after(0, () => {
834 void step($)
835 })
836}
837
838function fillOf(percent: number, tokens: number | undefined): number {
839 const byPercent = percent / config.limitPercent
840 const byTokens = config.limitTokens > 0 && tokens !== undefined ? tokens / config.limitTokens : 0
841
842 return Math.max(byPercent, byTokens)
843}
844
845async function onMeasure($: EngineInterface, e: SessionMeasureInput): Promise<void> {
846 const b = await bindingNow($)
847
848 if (b === null) {
849 return
850 }
851
852 const percent = e.context.percent
853
854 showStatus($, b, percent)
855
856 if (percent === undefined || config.mode === 'off') {
857 return
858 }
859
860 const state = await sequenceNow($)
861
862 if (isOpen(state.phase)) {
863 armTick($, state)
864
865 return
866 }
867
868 const fill = fillOf(percent, e.context.tokens)
869
870 if (fill < config.warnPercent / config.limitPercent) {
871 if (!state.isArmed || state.hasWarned || state.hasNagged) {
872 await patch($, { isArmed: true, hasWarned: false, hasNagged: false })
873 note($, `armed (context ${percent}%)`)
874 }
875
876 return
877 }
878
879 if (fill < 1) {
880 if (state.isArmed && !state.hasWarned) {
881 await patch($, { hasWarned: true })
882 $.ui.toast(`context ${percent}%. Handoff and compaction start at ${config.limitPercent}%.`)
883 }
884
885 return
886 }
887
888 if (!state.isArmed || config.mode === 'ask') {
889 if (!state.hasNagged) {
890 await patch($, { hasNagged: true })
891 $.ui.toast(
892 state.isArmed
893 ? `context ${percent}% is at the limit. Run /orch handoff.`
894 : `context ${percent}% is at the limit again and did not drop below ${config.warnPercent}% after the last handoff. No new sequence. Run /orch handoff to force one.`,
895 )
896 note($, `at the limit (context ${percent}%), no sequence: ${state.isArmed ? 'mode ask' : 'not armed'}`)
897 }
898
899 return
900 }
901
902 await requestHandoff($, `context ${percent}% is at the limit of ${config.limitPercent}%`)
903}
904
905/** True while a signaled handoff is recent: its compaction is in the queue, or it ran in the last minutes. */
906async function isHandoffFresh($: EngineInterface): Promise<boolean> {
907 const state = await sequenceNow($)
908
909 if (state.phase === 'compacting') {
910 return true
911 }
912
913 return state.lastEnd.startsWith('compacted') && (await $.clock.now()) - state.lastEndAt <= FRESH_WINDOW_MS
914}
915
916function sayNotFresh($: EngineInterface, b: OrchBinding): void {
917 const text = `the engine compacted with no handoff signal before it. ${b.readmeRel} can be out of date: check it against Linear.`
918
919 note($, text)
920 $.ui.toast(`${text}`, { timeoutMs: 15000 })
921}
922
923function instructionsOf(e: SessionCompactInput, b: OrchBinding, isFresh: boolean): string {
924 const parts = [projectLineOf(b)]
925
926 if (!isFresh) {
927 parts.push(
928 `No handoff was signaled before this compaction: say in the summary that ${b.readmeRel} can be out of date and must be checked against Linear.`,
929 )
930 }
931
932 if (e.instructions !== undefined && e.instructions.trim() !== '') {
933 parts.push(e.instructions)
934 }
935
936 return parts.join('\n\n')
937}
938
939/**
940 * `/orch new <slug> [goal]`: writes the project's README where none is, then
941 * binds as `/orch <slug>` does. A README that exists is never written.
942 */
943async function create($: EngineInterface, slug: string, goal: string): Promise<{ text: string; context?: readonly string[] }> {
944 if (!isNewSlug(slug)) {
945 return {
946 text: `"${slug}" is not a name for a new project: lowercase letters, digits and "-" only, and not new, off, handoff or done. ${USAGE}`,
947 }
948 }
949
950 const readmeRel = `${config.root}/${slug}/README.md`
951 const readme = `${await $.session.cwd()}/${readmeRel}`
952
953 if (await $.fs.exists(readme)) {
954 return { text: `${readmeRel} exists, use /orch ${slug}` }
955 }
956
957 await $.fs.write(readme, scaffoldOf(slug, goal, new Date(await $.clock.now()).toISOString()))
958 note($, `created ${readmeRel}`)
959
960 const bound = await bind($, slug)
961
962 if (typeof bound === 'string') {
963 return { text: `created ${readmeRel}, but the bind failed: ${bound}` }
964 }
965
966 return {
967 text: [
968 `created ${readmeRel}`,
969 'lanes/, briefs/ and docs/ are not created: the mod API has no call that makes a folder. Each one appears when its first file is written.',
970 ...(bound.goal === GOAL_TODO ? ['no goal was typed: write it in the README frontmatter, then run /orch ' + slug] : []),
971 describe(bound),
972 ].join('\n'),
973 context: [roleMessageOf(bound)],
974 }
975}
976
977async function onCommand($: EngineInterface, args: string): Promise<{ text: string; context?: readonly string[] }> {
978 const word = args.trim()
979 const b = await bindingNow($)
980
981 if (word === '') {
982 const slugs = await slugsOf($)
983
984 return {
985 text: [
986 b === null ? 'not bound' : describe(b),
987 ...(b === null ? [] : describeSequence(await sequenceNow($), await $.clock.now(), b)),
988 `projects: ${slugs.join(', ') || `none under ${config.root}/`}`,
989 USAGE,
990 ].join('\n'),
991 }
992 }
993
994 if (/^new(\s|$)/.test(word)) {
995 const [, slug = '', ...goal] = word.split(/\s+/)
996
997 return create($, slug, goal.join(' '))
998 }
999
1000 if (word === 'off') {
1001 if (b === null) {
1002 return { text: 'not bound' }
1003 }
1004
1005 await unbind($)
1006
1007 return { text: `unbound from ${b.slug}` }
1008 }
1009
1010 if (word === 'handoff') {
1011 if (b === null) {
1012 return { text: `not bound. ${USAGE}` }
1013 }
1014
1015 const hasStarted = await requestHandoff($, 'by command')
1016
1017 const state = await sequenceNow($)
1018
1019 return {
1020 text: hasStarted
1021 ? `handoff requested for ${b.slug}. Compaction follows the handoff signal.\ncode: ${state.code}\n${signalHelpOf(b, state.code)}`
1022 : `a handoff sequence is in flight (${state.phase}). /orch shows where it is.`,
1023 }
1024 }
1025
1026 if (word === 'done') {
1027 if (b === null) {
1028 return { text: `not bound. ${USAGE}` }
1029 }
1030
1031 const state = await sequenceNow($)
1032
1033 if (state.phase === 'compacting') {
1034 return {
1035 text: `the handoff signal is in already (${state.signal}); /compact ${state.askedAt > 0 ? 'is in the queue' : 'follows'}.`,
1036 }
1037 }
1038
1039 if (!isOpen(state.phase)) {
1040 return {
1041 text: `no handoff sequence is open${state.lastEnd === '' ? '' : ` (last sequence: ${state.lastEnd})`}. /orch handoff starts one.`,
1042 }
1043 }
1044
1045 if ((await signal($, state.code, 'the person, /orch done', '')) !== 'ok') {
1046 return { text: 'the sequence moved on while this command ran. /orch shows where it is.' }
1047 }
1048
1049 stepSoon($)
1050
1051 return { text: `handoff marked as written by hand for ${b.slug}. /compact runs between turns, then the restart prompt.` }
1052 }
1053
1054 const bound = await bind($, word)
1055
1056 if (typeof bound === 'string') {
1057 return { text: bound }
1058 }
1059
1060 return {
1061 text: describe(bound),
1062 context: [roleMessageOf(bound)],
1063 }
1064}
1065
1066type ToolAnswer = { result: string } | { deny: string }
1067
1068/** A `handoff_done` call for the project this session is bound to: the main loop's, a subagent's or a teammate's. */
1069async function signalHere(
1070 $: EngineInterface,
1071 b: OrchBinding,
1072 code: string,
1073 detail: string,
1074 agentId: string | undefined,
1075): Promise<ToolAnswer> {
1076 const verdict = await signal($, code, agentId === undefined ? `tool ${TOOL_NAME}` : `tool ${TOOL_NAME}, agent ${agentId}`, detail)
1077 const state = await sequenceNow($)
1078
1079 if (verdict === 'ok') {
1080 stepSoon($)
1081
1082 return {
1083 result: `accepted: the handoff of ${b.slug} is signaled. The orch mod compacts the orchestrator session between turns. Stop now: do not start new work.`,
1084 }
1085 }
1086
1087 if (verdict === 'used') {
1088 return {
1089 deny: `code ${code} was used already for ${b.slug}: the signal is in${state.phase === 'compacting' ? ' and the compaction is in the queue' : ''}. Do not call again.`,
1090 }
1091 }
1092
1093 if (verdict === 'wrong') {
1094 return {
1095 deny: `wrong code for ${b.slug}: ${code} is not the code of the open handoff request. Nothing changed. The person reads the open code with /orch.`,
1096 }
1097 }
1098
1099 return {
1100 deny: `no handoff sequence is open for ${b.slug}${state.lastEnd === '' ? '' : ` (last sequence: ${state.lastEnd})`}. Nothing changed. /orch handoff starts one.`,
1101 }
1102}
1103
1104/**
1105 * A `handoff_done` call in a session that is not the project's orchestrator
1106 * (a lane in a process of its own): checks the code against the orchestrator's
1107 * open record, leaves a record for its mod, and sends it a message.
1108 */
1109async function relay(
1110 $: EngineInterface,
1111 b: OrchBinding | null,
1112 slug: string,
1113 code: string,
1114 detail: string,
1115): Promise<ToolAnswer> {
1116 const open = await $.store.get(openKeyOf(slug))
1117 const here = b === null ? 'This session is not bound to a project' : `This session is bound to ${b.slug}`
1118
1119 if (!isOpenRecord(open)) {
1120 return {
1121 deny: `no handoff sequence is open for "${slug}" on this machine. ${here}. Nothing changed. Check the slug; where the handoff request names a marker file, write the code there.`,
1122 }
1123 }
1124
1125 if (open.code !== code) {
1126 return { deny: `wrong code for ${slug}: ${code} is not the code of the open handoff request. Nothing changed.` }
1127 }
1128
1129 const from = await $.session.id()
1130 const record: OrchDoneRecord = { code, at: await $.clock.now(), note: detail, from }
1131
1132 await $.store.set(doneKeyOf(slug), record)
1133
1134 let failure = ''
1135
1136 try {
1137 const sent = await $.session.send({ to: { sessionId: open.sessionId }, text: `${RELAY_MARK} slug=${slug} code=${code}` })
1138
1139 failure = sent.isDelivered ? '' : sent.reason
1140 } catch (error) {
1141 failure = messageOf(error)
1142 }
1143
1144 note($, `${TOOL_NAME} for ${slug} relayed to session ${open.sessionId}: ${failure === '' ? 'delivered' : `record only (${failure})`}`)
1145
1146 return {
1147 result:
1148 failure === ''
1149 ? `accepted: relayed to the orchestrator session of ${slug}. It compacts between turns. Stop now.`
1150 : `accepted: recorded for the orchestrator session of ${slug}; its mod reads the record in ${POLL_MS / 1000} s or at its next turn end (the direct message was not delivered: ${failure}). Stop now.`,
1151 }
1152}
1153
1154export const register: Register = (on, options) => {
1155 config = configOf(options)
1156
1157 on('session.start', async ($, e, next) => {
1158 isInteractive = e.isInteractive
1159 held = null
1160 boundNow = undefined
1161 cancelTick()
1162
1163 await $.command.register({
1164 name: 'orch',
1165 description: 'Bind this session to an orchestrator project, or run handoff and compaction now',
1166 argumentHint: '[slug | new <slug> [goal] | off | handoff | done]',
1167 })
1168 // In every session, bound or not: a lane's or a scribe's own session makes the call too.
1169 await $.tool.register({
1170 name: TOOL_NAME,
1171 description: TOOL_TEXT,
1172 inputSchema: {
1173 type: 'object',
1174 properties: {
1175 slug: { type: 'string', description: 'The project slug, as the handoff request gives it.' },
1176 code: { type: 'string', description: 'The one-time code of the handoff request.' },
1177 note: { type: 'string', description: 'Optional: one line on what was written.' },
1178 },
1179 required: ['slug', 'code'],
1180 },
1181 })
1182
1183 const b = await bindingNow($)
1184
1185 if (b !== null) {
1186 showStatus($, b, await percentNow($))
1187 }
1188
1189 // A reload drops the timers of the module it replaces; the sequence is in `$.state`.
1190 const state = await sequenceNow($)
1191
1192 if (isOpen(state.phase) && state.phase !== 'compacting' && state.code === '') {
1193 // Version 0.2 waited for the README to change and gave no code: nothing can signal this one.
1194 await stop($, `the sequence was started by an older version of the mod and has no code (phase ${state.phase})`, 'Run /orch handoff.')
1195 } else if (isOpen(state.phase)) {
1196 note($, `sequence open at the start of this load (${state.phase}); continuing`)
1197 armTick($, state)
1198 }
1199
1200 return next(e)hooks/project.ts 196 lines1export type Project = {
2 goal: string
3 linearProject: string
4 canonical: string[]
5 role: string
6 hasFrontmatter: boolean
7 lines: number
8}
9
10export const FRONTMATTER_KEYS = ['goal', 'linear_project', 'canonical', 'role'] as const
11
12function unquote(value: string): string {
13 const text = value.trim()
14 const first = text[0]
15 const isQuoted = text.length >= 2 && (first === '"' || first === "'") && text.endsWith(first)
16
17 if (!isQuoted) {
18 return text
19 }
20
21 if (first === '"') {
22 try {
23 const parsed: unknown = JSON.parse(text)
24
25 if (typeof parsed === 'string') {
26 return parsed
27 }
28 } catch {
29 // not a JSON string: the text between the quotes stands as written
30 }
31 }
32
33 return text.slice(1, -1)
34}
35
36function inlineList(value: string): string[] {
37 return value
38 .slice(1, -1)
39 .split(',')
40 .map(unquote)
41 .filter(item => item !== '')
42}
43
44/**
45 * Reads the README's frontmatter by hand: single-line values, `|` and `>`
46 * blocks, `- item` lists and `[a, b]` lists. No other YAML.
47 */
48function frontmatterOf(lines: readonly string[]): Record<string, string | string[]> | null {
49 if (lines[0]?.replace(/^/, '').trim() !== '---') {
50 return null
51 }
52
53 const end = lines.findIndex((line, index) => index > 0 && /^(---|\.\.\.)\s*$/.test(line))
54
55 if (end === -1) {
56 return null
57 }
58
59 const found: Record<string, string | string[]> = {}
60 let index = 1
61
62 while (index < end) {
63 const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[index] ?? '')
64 index += 1
65
66 if (match === null) {
67 continue
68 }
69
70 const key = match[1] ?? ''
71 const value = (match[2] ?? '').trim()
72 const below: string[] = []
73
74 while (index < end && /^(\s+\S|\s*$)/.test(lines[index] ?? '')) {
75 below.push(lines[index] ?? '')
76 index += 1
77 }
78
79 if (/^[|>][+-]?$/.test(value)) {
80 const body = below.map(line => line.trim())
81 found[key] = (value.startsWith('|') ? body.join('\n') : body.join(' ')).trim()
82 } else if (value === '') {
83 found[key] = below
84 .map(line => /^\s*-\s+(.*)$/.exec(line)?.[1])
85 .filter((item): item is string => item !== undefined)
86 .map(unquote)
87 } else if (value.startsWith('[') && value.endsWith(']')) {
88 found[key] = inlineList(value)
89 } else {
90 found[key] = unquote(value)
91 }
92 }
93
94 return found
95}
96
97function textOf(value: string | string[] | undefined): string {
98 if (value === undefined) {
99 return ''
100 }
101
102 return typeof value === 'string' ? value : value.join(', ')
103}
104
105function listOf(value: string | string[] | undefined): string[] {
106 if (value === undefined || value === '') {
107 return []
108 }
109
110 return typeof value === 'string' ? [value] : value
111}
112
113/**
114 * The project data of one README: its frontmatter, or with none the first
115 * `# ` heading as the goal.
116 */
117export function parseProject(text: string, slug: string): Project {
118 const lines = text.replace(/\r\n/g, '\n').split('\n')
119 const count = lines.at(-1) === '' ? lines.length - 1 : lines.length
120 const found = frontmatterOf(lines)
121 const heading = lines.map(line => /^#\s+(.+)$/.exec(line)?.[1]?.trim()).find(Boolean)
122 const goal = textOf(found?.goal)
123
124 return {
125 goal: goal !== '' ? goal : (heading ?? slug),
126 linearProject: textOf(found?.linear_project),
127 canonical: listOf(found?.canonical),
128 role: textOf(found?.role),
129 hasFrontmatter: found !== null,
130 lines: count,
131 }
132}
133
134function escaped(text: string): string {
135 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
136}
137
138/**
139 * The distinct slugs a text names as `<root>/<slug>/README.md`.
140 */
141export function slugsIn(text: string, root: string): string[] {
142 const pattern = new RegExp(`${escaped(root)}/([A-Za-z0-9][A-Za-z0-9._-]*)/README\\.md`, 'g')
143 const slugs = [...text.matchAll(pattern)].map(match => match[1] ?? '')
144
145 return [...new Set(slugs)].filter(slug => slug !== '' && !slug.includes('..'))
146}
147
148export function isSlug(text: string): boolean {
149 return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(text) && !text.includes('..')
150}
151
152export const RESERVED_WORDS = ['new', 'off', 'handoff', 'done'] as const
153
154export const GOAL_TODO = 'TODO: write the goal of this project'
155
156/**
157 * A name `/orch new` takes: lowercase letters, digits and `-`, no dot and no
158 * path separator, and not a word the command itself reads.
159 */
160export function isNewSlug(text: string): boolean {
161 return /^[a-z0-9][a-z0-9-]*$/.test(text) && !(RESERVED_WORDS as readonly string[]).includes(text)
162}
163
164/**
165 * The README a new project starts with: a pointer file. The goal is written
166 * as a JSON string, which is a YAML double-quoted value too.
167 */
168export function scaffoldOf(slug: string, goal: string, updatedAt: string): string {
169 return `---
170goal: ${JSON.stringify(goal === '' ? GOAL_TODO : goal)}
171linear_project: ""
172canonical: []
173---
174# ${slug}
175
176last updated: ${updatedAt}
177
178## State
179Nothing yet. Write the current state here, or point to a state file.
180
181## Open lanes
182None.
183
184## Pending decisions
185None.
186
187## Restart steps
1881. Read this file, then the files under Pointers that you need.
189
190## Pointers
191- \`lanes/\`: one file for each lane.
192- \`briefs/\`: the lane briefs.
193- \`docs/\`: reference documents.
194`
195}
196hooks/role.ts 59 lines1/** The orchestrator role section. `fillRole` replaces each `{name}`. */
2export const ROLE_TEMPLATE = `# Orchestrator role (orch mod)
3
4This section applies to the main session only. If your prompt says that you are a subagent, a teammate, a lane or a fork, ignore this section and do your assigned task.
5
6You are the orchestrator for project \`{slug}\`: {goal}
7You organize and delegate. You do not do lane work yourself unless it is necessary.
8
9Entry point: \`{readme}\`. It is a pointer file. Read it first after each start and each compaction. Then open only the files it points to that you need.
10
11Layout: \`{dir}/\` holds README.md (pointers and current state), \`lanes/\`, \`briefs/\`, \`docs/\`.
12
13Canonical sources:
14- Linear is canonical for plans, decisions and ticket state ({linear_project}).
15- \`{root}/\` is short-lived session memory. It is not an authority. If a decision is only there, move it to Linear in the same session.
16- AGENTS.md wins on a conflict.
17{canonical}
18
19Handoff: keep the README current. When the orch mod asks for a handoff update, write state, open lanes, pending decisions and restart steps (or have the scribe lane write them). Then call the tool \`mcp__orch__handoff_done\` with the slug and the code from that request (the lane that writes the handoff can make the call), and stop. Compaction follows that call, and nothing else starts it.
20
21Context budget: stay below {limit}% context. Delegate reads that give large output.
22{role}`
23
24export type RoleValues = {
25 slug: string
26 goal: string
27 readme: string
28 dir: string
29 root: string
30 linear_project: string
31 canonical: readonly string[]
32 limit: number
33 role: string
34}
35
36/**
37 * Fills the template in one pass, so a value that holds `{name}` stays as written.
38 */
39export function fillRole(values: RoleValues): string {
40 const words: Record<string, string> = {
41 slug: values.slug,
42 goal: values.goal,
43 readme: values.readme,
44 dir: values.dir,
45 root: values.root,
46 linear_project:
47 values.linear_project === ''
48 ? 'the project is not set in the README frontmatter'
49 : `project: ${values.linear_project}`,
50 canonical: values.canonical.map(item => `- ${item}`).join('\n'),
51 limit: String(values.limit),
52 role: values.role,
53 }
54
55 return ROLE_TEMPLATE.replace(/\{([a-z_]+)\}/g, (whole, name: string) => words[name] ?? whole)
56 .replace(/\n{3,}/g, '\n\n')
57 .trim()
58}
59types/index.d.ts 74 lines1export type OrchBinding = {
2 slug: string
3 /** Absolute path of the README. */
4 readme: string
5 /** The README path as prompts name it, relative to the session directory. */
6 readmeRel: string
7 /** The handoff root as configured (`.remember`). */
8 root: string
9 goal: string
10 linearProject: string
11 canonical: string[]
12 role: string
13 hasFrontmatter: boolean
14 lines: number
15}
16
17export type OrchPhase = 'idle' | 'requested' | 'running' | 'waiting' | 'compacting'
18
19/**
20 * One handoff sequence: `requested` (the prompt is submitted), `running` (the
21 * handoff turn), `waiting` (the turn ended, no signal yet), `compacting` (the
22 * signal came and /compact is asked for, or about to be), then `idle`.
23 *
24 * The signal is the `handoff_done` tool called with the open code, the same
25 * code in the marker file, or the person's `/orch done`. No file time counts.
26 */
27export type OrchSequence = {
28 phase: OrchPhase
29 /** When the handoff was requested, milliseconds since the epoch. */
30 startedAt: number
31 /** The id of the handoff turn, once it started. */
32 turnId: string
33 /** When the phase began. */
34 phaseAt: number
35 /** When /compact was asked for; 0 while it was not. */
36 askedAt: number
37 /** How the last sequence ended; empty when none did. */
38 lastEnd: string
39 lastEndAt: number
40 /** False after one sequence, until the fill drops below the warning level. */
41 isArmed: boolean
42 hasWarned: boolean
43 hasNagged: boolean
44 /** The one-time code of the open sequence; empty while none is open. */
45 code: string
46 /** The last code that was taken as a signal: a second call with it reads "used". */
47 usedCode: string
48 /** How the signal of the open sequence came (tool, relay, marker file, the person); empty before it. */
49 signal: string
50}
51
52/** What `$.store` holds under `open/<slug>` while a sequence waits for its signal. */
53export type OrchOpenRecord = {
54 code: string
55 /** The orchestrator session, as its `$.session.id()` answers. */
56 sessionId: string
57 at: number
58}
59
60/** What a `handoff_done` call in another session leaves under `done/<slug>`. */
61export type OrchDoneRecord = {
62 code: string
63 at: number
64 note: string
65 /** The session that made the call. */
66 from: string
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 orch: { binding: OrchBinding | null; sequence: OrchSequence }
72 }
73}
74