deepseek-coordination 0.1.9 → 0.10.0

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.
@@ -1,1494 +0,0 @@
1
- /**
2
- * dsh-os workflow: one request in, a decided plan out, and a context that does not grow with the work.
3
- *
4
- * WHY THIS EXISTS
5
- *
6
- * The failure mode is a request that was never a specification. The model is handed an instruction,
7
- * starts editing files, and the divergence is discovered at review time — after the tokens are spent.
8
- * Three things are missing, and each is cheap before execution and expensive after it: what the user
9
- * actually wants, the shape of the work, and which trade-off the user prefers.
10
- *
11
- * THE PHASES
12
- *
13
- * intake -> plan -> choose -> run -> done
14
- *
15
- * `intake` records the request verbatim and asks at most three questions about intent. `plan` writes the
16
- * design-space record: approach, process, alternatives, criteria, recommendation. `choose` asks the USER
17
- * and records their decision — the model cannot answer it. `run` executes the chosen option.
18
- *
19
- * All four steps the feature was asked for are here — ask the real intent, organise the architecture and
20
- * process, list the options with pros and cons, let the user choose, then execute. They are four calls
21
- * rather than seven because steps that share a decision were fused, and that fusion is the optimisation:
22
- *
23
- * * the design and its alternatives are ONE decision, so they are ONE call and ONE record. This is the
24
- * QOC shape (Questions, Options, Criteria) and the ADR shape (Context, Decision, Consequences): a
25
- * decision record holds the alternatives it was chosen between and the criteria it was judged
26
- * against. Splitting them into `design` then `options` made the second call re-derive the first and
27
- * made the user read the same architecture twice.
28
- * * a clear intent is not worth a question. `intake` accepts `assumptions` instead of `questions`, so an
29
- * unambiguous request costs zero human interactions instead of one — and the assumption is on record
30
- * where the user can correct it, which is cheaper than the question it replaced.
31
- * * a single-step execution is not worth a separate call: `run` takes the outcome and closes the
32
- * workflow when one step finished it.
33
- *
34
- * Measured by `tests/workflow.test.mjs`, the unavoidable path went from eight tool calls and two human
35
- * interactions to four and one, for the same recorded phases and the same gate on execution.
36
- *
37
- * THE COMPRESSION, WHICH IS THE OTHER HALF
38
- *
39
- * Narrating a plan into the conversation is what makes a short task expensive: the detail enters the
40
- * request history and is re-sent with every later step. So it never enters it. Each phase writes a real
41
- * document and returns a compact envelope — phase, one line, file path, the single next call. Later steps
42
- * read what they need from the file. The evidence for this shape is measured, not stylistic: pruning plus
43
- * summarisation has been shown to improve completion while cutting tokens and runtime
44
- * (https://export.arxiv.org/pdf/2608.16370), and a planner/executor split matches interleaved reasoning at
45
- * a fraction of the token cost (https://zenodo.org/records/22904595).
46
- *
47
- * COST, STATED AS COMPLEXITY, BECAUSE THAT IS WHAT IT IS
48
- *
49
- * * one `readState` per call, cached on the file's mtime. The prompt section also reads state, on every
50
- * model step, so the cache is what keeps that from being a parse per step.
51
- * * one `writeAtomic` per state transition. The first version wrote state three times per action,
52
- * because each phase appended to its own document and then re-saved.
53
- * * appends that grow linearly, not quadratically: a note during execution is ONE appended journal
54
- * line, and the journal is folded into `state.json` only on a checkpoint or when it outgrows its
55
- * bound. Re-reading and rewriting the whole state on every note was O(n^2) in the number of notes.
56
- * * a bounded prompt section that does not change within a phase. It is re-sent with every step, so it
57
- * is the one part of this feature that has to earn its tokens — and a stable prefix is also what lets
58
- * the provider's prompt cache hit (https://arxiv.org/pdf/2601.06007v1).
59
- *
60
- * STATE LOCATION. `$DSH_HOME/dsh-os/workflows/<session>/`, beside the settings document and for the same
61
- * reason: the package directory is replaced on upgrade, and the workspace belongs to the user rather
62
- * than to this plugin. `state.json` is the compact record; every other file is the detail.
63
- */
64
-
65
- import { randomBytes } from 'node:crypto'
66
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
67
- import { dirname, join } from 'node:path'
68
-
69
- import { PKG_ROOT } from './invoke.js'
70
-
71
- export const name = 'dsh-os-workflow'
72
-
73
- /** The tool registry and the human answerer are both required; the prompt registry is optional. */
74
- export const inject = ['tools', 'userQuestions']
75
-
76
- /**
77
- * The phases, in order, and the ranks the gates compare.
78
- *
79
- * `intake` -> `plan` -> `choose` -> `run` -> `done`. Kept deliberately short: every phase boundary is a
80
- * gate a caller can be refused at, and a process with more gates than decisions is ceremony.
81
- */
82
- export const PHASES = ['intake', 'plan', 'choose', 'run', 'done']
83
-
84
- /** Bounds. Every list that can grow has one, because part of this state is re-read on every model step. */
85
- const HISTORY_LIMIT = 100
86
- const STEP_LIMIT = 100
87
- const JOURNAL_LIMIT = 60
88
- const JOURNAL_BYTES_LIMIT = 24 * 1024
89
- const SUMMARY_MAX = 280
90
- const INTENT_MAX = 4000
91
-
92
- /** The version written into every state document, so a future change can migrate rather than guess. */
93
- const STATE_VERSION = 2
94
-
95
- /**
96
- * One state document per session, cached on the file's modification time.
97
- *
98
- * The prompt section reads state on every model step and a tool call reads it once; without the cache
99
- * that is a read and a `JSON.parse` per step for a value that changes only when this plugin changes it.
100
- * The mtime check is what keeps the cache honest when the document is edited by hand between steps.
101
- */
102
- const stateCache = new Map()
103
-
104
- /** Bytes currently in each session's journal, so the append path can stay O(1) and still be bounded. */
105
- const journalBytes = new Map()
106
-
107
- /** Drop a session's cached state. Called after every write, so a stale read is not possible. */
108
- function invalidate(file) {
109
- stateCache.delete(file)
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // identity and location
114
- // ---------------------------------------------------------------------------
115
-
116
- /**
117
- * The bucket a session id is filed under.
118
- *
119
- * A session id reaches the filesystem as a directory name, so everything outside a conservative set is
120
- * replaced. Two ids can therefore collide in principle; the cost of a collision is a shared workflow,
121
- * while the alternative — trusting a caller-supplied id as a path segment — is a traversal.
122
- */
123
- export function sessionKeyOf(raw) {
124
- const text = String(raw ?? '')
125
- .replace(/^[A-Za-z]+\((.*)\)$/, '$1') // branded ids appear either as `SessionId('x')` or as `x`
126
- .replace(/^['"]|['"]$/g, '')
127
- .trim()
128
- return text.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 96) || 'unknown'
129
- }
130
-
131
- /**
132
- * The session this call belongs to.
133
- *
134
- * Both spellings are accepted because both are separately evidenced in the runtime: `dsh-tool-fs` reads
135
- * `exec.agent.session.header.cwd`, while the subagent tools use `exec.agent.id` for ownership.
136
- */
137
- export function sessionKeyOfExecution(exec) {
138
- const agent = exec?.agent
139
- const id = agent?.session?.header?.id ?? agent?.id
140
- if (id === undefined || id === null || String(id).trim() === '') {
141
- throw new Error(
142
- 'the workflow_plan tool needs an owning agent session: it keys its state, its artifacts and its prompt ' +
143
- 'section by session, and a call with no session would share one plan with every other caller',
144
- )
145
- }
146
- return sessionKeyOf(id)
147
- }
148
-
149
- /** Where the workflow documents live, with the same fallback order the settings service documents. */
150
- export function workflowRoot(env = process.env) {
151
- const home = env.DSH_HOME
152
- if (typeof home === 'string' && home.trim() !== '') return join(home, 'dsh-os', 'workflows')
153
- return join(PKG_ROOT, '.dsh-os-workflows')
154
- }
155
-
156
- export function sessionDirectory(root, key) {
157
- return join(root, key)
158
- }
159
-
160
- export function statePathOf(root, key) {
161
- return join(sessionDirectory(root, key), 'state.json')
162
- }
163
-
164
- /** The append-only journal: one JSON object per line, for entries that must not cost a state rewrite. */
165
- export function journalPathOf(root, key) {
166
- return join(sessionDirectory(root, key), 'journal.jsonl')
167
- }
168
-
169
- // ---------------------------------------------------------------------------
170
- // state
171
- // ---------------------------------------------------------------------------
172
-
173
- /** A fresh state document. `intent` is the root of everything recorded later. */
174
- export function emptyState(intent, now = new Date().toISOString()) {
175
- return {
176
- version: STATE_VERSION,
177
- createdAt: now,
178
- updatedAt: now,
179
- intent,
180
- cwd: null,
181
- phase: 'intake',
182
- answers: [],
183
- assumptions: [],
184
- decisions: [],
185
- steps: [],
186
- evidence: [],
187
- notes: [],
188
- checkpoints: [],
189
- artifacts: {},
190
- history: [{ at: now, phase: 'intent', summary: summarize(intent) }],
191
- }
192
- }
193
-
194
- /**
195
- * Read a session's state, or `null`.
196
- *
197
- * Never throws, and never re-parses an unchanged document. A half-written or hand-edited document is
198
- * treated as absent, because the alternative is a plugin that cannot answer a tool call at all — and
199
- * `writeAtomic` makes a half-written document unreachable in the first place.
200
- */
201
- export function readState(file) {
202
- try {
203
- const stamp = statSync(file)
204
- const cached = stateCache.get(file)
205
- if (cached !== undefined && cached.mtimeMs === stamp.mtimeMs && cached.size === stamp.size) return cached.state
206
- const raw = readFileSync(file, 'utf8')
207
- if (raw.trim() === '') {
208
- stateCache.set(file, { mtimeMs: stamp.mtimeMs, size: stamp.size, state: null })
209
- return null
210
- }
211
- const parsed = JSON.parse(raw)
212
- const state = parsed === null || typeof parsed !== 'object' || typeof parsed.phase !== 'string'
213
- ? null
214
- : normalizeState(parsed)
215
- stateCache.set(file, { mtimeMs: stamp.mtimeMs, size: stamp.size, state })
216
- return state
217
- } catch {
218
- // A missing document, a permission failure and malformed JSON all mean the same thing to a caller:
219
- // there is no usable state here. The cache entry is dropped so the next call re-reads rather than
220
- // repeating a stale answer.
221
- stateCache.delete(file)
222
- return null
223
- }
224
- }
225
-
226
- /** Keep only known keys and known shapes, so a hand-edited file cannot change what the plugin does. */
227
- export function normalizeState(parsed) {
228
- const state = emptyState(typeof parsed.intent === 'string' ? parsed.intent : '')
229
- state.version = STATE_VERSION
230
- state.createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : state.createdAt
231
- state.updatedAt = typeof parsed.updatedAt === 'string' ? parsed.updatedAt : state.updatedAt
232
- state.cwd = typeof parsed.cwd === 'string' ? parsed.cwd : null
233
- state.phase = PHASES.includes(parsed.phase) ? parsed.phase : 'intake'
234
- state.answers = Array.isArray(parsed.answers) ? parsed.answers : []
235
- state.assumptions = Array.isArray(parsed.assumptions) ? parsed.assumptions.filter(isText) : []
236
- state.decisions = Array.isArray(parsed.decisions) ? parsed.decisions.filter(isText) : []
237
- state.steps = Array.isArray(parsed.steps)
238
- ? parsed.steps
239
- .filter((step) => step !== null && typeof step === 'object' && typeof step.text === 'string')
240
- .map((step) => ({ text: step.text, status: step.status === 'done' ? 'done' : 'pending' }))
241
- : []
242
- state.evidence = Array.isArray(parsed.evidence) ? parsed.evidence.filter(isText) : []
243
- state.notes = Array.isArray(parsed.notes) ? parsed.notes.filter(isText) : []
244
- state.checkpoints = Array.isArray(parsed.checkpoints) ? parsed.checkpoints : []
245
- state.artifacts = parsed.artifacts !== null && typeof parsed.artifacts === 'object' ? { ...parsed.artifacts } : {}
246
- state.history = Array.isArray(parsed.history)
247
- ? parsed.history.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.phase === 'string')
248
- : state.history
249
- // The fields that carry one phase's output into the next one. They are re-read from disk on every
250
- // call, so a field dropped here is a field `choose` or `run` would no longer see.
251
- if (typeof parsed.goal === 'string') state.goal = parsed.goal
252
- if (typeof parsed.outcome === 'string') state.outcome = parsed.outcome
253
- if (typeof parsed.criteria === 'string') state.criteria = parsed.criteria
254
- if (typeof parsed.recommendation === 'string') state.recommendation = parsed.recommendation
255
- if (Array.isArray(parsed.files)) state.files = parsed.files.filter(isText)
256
- if (Array.isArray(parsed.constraints)) state.constraints = parsed.constraints.filter(isText)
257
- if (Array.isArray(parsed.plan)) state.plan = parsed.plan.filter(isText)
258
- if (Array.isArray(parsed.options)) {
259
- state.options = parsed.options
260
- .filter((option) => option !== null && typeof option === 'object' && typeof option.title === 'string')
261
- .map((option) => ({
262
- id: typeof option.id === 'string' ? option.id : '',
263
- title: option.title,
264
- pros: Array.isArray(option.pros) ? option.pros.filter(isText) : [],
265
- cons: Array.isArray(option.cons) ? option.cons.filter(isText) : [],
266
- criteria: typeof option.criteria === 'string' ? option.criteria : '',
267
- recommended: option.recommended === true,
268
- }))
269
- }
270
- if (parsed.choice !== null && typeof parsed.choice === 'object' && typeof parsed.choice.title === 'string') {
271
- state.choice = {
272
- id: typeof parsed.choice.id === 'string' ? parsed.choice.id : null,
273
- title: parsed.choice.title,
274
- verbatim: typeof parsed.choice.verbatim === 'string' ? parsed.choice.verbatim : parsed.choice.title,
275
- options: Array.isArray(parsed.choice.options) ? parsed.choice.options.filter(isText) : [],
276
- }
277
- }
278
- return state
279
- }
280
-
281
- function isText(value) {
282
- return typeof value === 'string'
283
- }
284
-
285
- /** Write a document atomically: sibling temporary file, then rename. */
286
- export function writeAtomic(file, text) {
287
- mkdirSync(dirname(file), { recursive: true })
288
- const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`
289
- try {
290
- writeFileSync(temporary, text, 'utf8')
291
- renameSync(temporary, file)
292
- } catch (error) {
293
- try { rmSync(temporary, { force: true }) } catch { /* the original failure is the one that matters */ }
294
- throw error
295
- }
296
- }
297
-
298
- /** Replace a whole document. One call per phase; a note never goes through here. */
299
- export function writeDocument(file, title, body) {
300
- writeAtomic(file, `# ${title}\n\n${String(body).trim()}\n`)
301
- return file
302
- }
303
-
304
- /** Append one section. Used at most once per phase, so growth stays linear in phases. */
305
- export function appendSection(file, title, body, now = new Date().toISOString()) {
306
- const existing = existsSync(file) ? readFileSync(file, 'utf8') : ''
307
- const header = existing === '' ? `# ${title}\n` : ''
308
- writeAtomic(file, `${existing}${header}\n## ${now}\n\n${String(body).trim()}\n`)
309
- return file
310
- }
311
-
312
- // ---------------------------------------------------------------------------
313
- // text distillation
314
- // ---------------------------------------------------------------------------
315
-
316
- /** One line for a summary field: whitespace collapsed and cut. */
317
- export function summarize(text, max = SUMMARY_MAX) {
318
- const flat = String(text ?? '').replace(/\s+/g, ' ').trim()
319
- return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
320
- }
321
-
322
- /**
323
- * Distil a longer document into the lines worth carrying forward.
324
- *
325
- * Headings and list items are preferred over prose because in a design document that is where the
326
- * decisions are; a plain paragraph falls back to its opening sentence. Bounded by `limit`, and the `Set`
327
- * removes the repetition a heading and its first bullet usually produce.
328
- */
329
- export function bulletsOf(text, limit = 6) {
330
- const picked = []
331
- for (const line of String(text ?? '').split(/\r?\n/)) {
332
- const trimmed = line.trim()
333
- if (trimmed === '') continue
334
- if (/^#{1,6}\s+/.test(trimmed)) {
335
- picked.push(summarize(trimmed.replace(/^#{1,6}\s+/, ''), 120))
336
- continue
337
- }
338
- if (/^([-*+]\s+|\d+[.)]\s+)/.test(trimmed)) {
339
- picked.push(summarize(trimmed.replace(/^([-*+]\s+|\d+[.)]\s+)/, ''), 160))
340
- continue
341
- }
342
- if (picked.length === 0) picked.push(summarize(trimmed, 160))
343
- if (picked.length >= limit) break
344
- }
345
- return [...new Set(picked)].slice(0, limit)
346
- }
347
-
348
- /** Keep a list of short strings, bounded, so no empty entry reaches a document. */
349
- export function trimList(value, limit = 24) {
350
- if (!Array.isArray(value)) return []
351
- const out = []
352
- for (const item of value) {
353
- const text = summarize(item, 200)
354
- if (text !== '') out.push(text)
355
- if (out.length >= limit) break
356
- }
357
- return out
358
- }
359
-
360
- // ---------------------------------------------------------------------------
361
- // gates
362
- // ---------------------------------------------------------------------------
363
-
364
- /**
365
- * Take the phase order from the state and refuse anything that would skip a gate.
366
- *
367
- * The message names the phase at hand AND the phase being asked for: a caller that has lost its place
368
- * should learn the next call from the refusal, not from a list of phases.
369
- */
370
- export function assertPhase(state, required) {
371
- if (state === null) {
372
- throw new Error(
373
- required === 'intake'
374
- ? 'this session has no workflow yet'
375
- : `no workflow is active for this session: call workflow_plan with action "start" and the user's own prompt before "${required}"`,
376
- )
377
- }
378
- const current = PHASES.indexOf(state.phase)
379
- const wanted = PHASES.indexOf(required)
380
- if (current < 0) throw new Error(`this session's workflow is in an unknown phase '${state.phase}'`)
381
- if (current > wanted) {
382
- throw new Error(
383
- `phase "${required}" is already behind this workflow (it is at "${state.phase}"): ` +
384
- `continue with "${state.phase}", or reset and start a new one`,
385
- )
386
- }
387
- if (current < wanted) {
388
- throw new Error(
389
- `phase "${required}" is out of order: this workflow is at "${state.phase}", so "${state.phase}" must be ` +
390
- `recorded first, and "${required}" comes after it — a later phase is refused rather than silently reordered`,
391
- )
392
- }
393
- return state
394
- }
395
-
396
- /** Whether this state is in the phase where a checkpoint is worth taking. */
397
- export function boundaryReached(state) {
398
- return state !== null && state.phase === 'run'
399
- }
400
-
401
- // ---------------------------------------------------------------------------
402
- // the handlers
403
- // ---------------------------------------------------------------------------
404
-
405
- /** The result envelope. Omitted keys are omitted rather than nulled: a smaller payload is the point. */
406
- function envelope(parts) {
407
- const value = { ok: true, phase: parts.phase }
408
- if (parts.summary !== undefined && parts.summary !== null && parts.summary !== '') value.summary = parts.summary
409
- // `next` is kept even when it is null: "there is no next phase" is a fact the caller acts on, and
410
- // dropping the key would leave it looking like a field the plugin forgot to fill in.
411
- if (parts.next !== undefined) value.next = parts.next
412
- if (parts.files !== undefined && Object.keys(parts.files).length > 0) value.files = parts.files
413
- if (parts.decisions !== undefined && parts.decisions.length > 0) value.decisions = parts.decisions
414
- if (parts.steps !== undefined && parts.steps.length > 0) value.steps = parts.steps
415
- if (parts.options !== undefined && parts.options.length > 0) value.options = parts.options
416
- if (parts.note !== undefined && parts.note !== null) value.note = parts.note
417
- return value
418
- }
419
-
420
- /**
421
- * The single next call, which is what keeps the model from re-deriving the process.
422
- *
423
- * The key is the phase the workflow HOLDS, and the value is the call that records it — not the phase
424
- * after it. `plan` means "the plan has not been recorded yet, call plan", which is the reading a caller
425
- * arriving at a phase name actually needs.
426
- */
427
- function nextInstruction(state) {
428
- switch (state?.phase) {
429
- case 'intake':
430
- return 'call workflow_plan with action "start": the user\'s prompt verbatim, plus 1-3 questions about intent or the assumptions you are making'
431
- case 'plan':
432
- return 'call workflow_plan with action "plan": the approach and process, at least two alternatives with pros and cons, and which one you recommend'
433
- case 'choose':
434
- return 'call workflow_plan with action "choose": the user picks, and their answer is recorded — do not decide for them'
435
- case 'run':
436
- return 'call workflow_plan with action "run" with the outcome to close it, or "note" for a decision worth keeping'
437
- default:
438
- return null
439
- }
440
- }
441
-
442
- export function makeHandlers({ root, ask, compact, logger = () => {} }) {
443
- const stateFile = (key) => statePathOf(root, key)
444
- const journalFile = (key) => journalPathOf(root, key)
445
- const artifact = (key, file) => join(sessionDirectory(root, key), file)
446
-
447
- function load(key) {
448
- return readState(stateFile(key))
449
- }
450
-
451
- /** One write per transition: persist, drop the cache entry, tell the logger. */
452
- function save(key, state) {
453
- const file = stateFile(key)
454
- writeAtomic(file, `${JSON.stringify(state, null, 2)}\n`)
455
- invalidate(file)
456
- logger({ phase: state.phase, session: key })
457
- return state
458
- }
459
-
460
- function record(state, phase, summary, now) {
461
- state.history.push({ at: now, phase, summary: summarize(summary) })
462
- if (state.history.length > HISTORY_LIMIT) state.history.splice(0, state.history.length - HISTORY_LIMIT)
463
- state.updatedAt = now
464
- }
465
-
466
- /**
467
- * Append one line to the journal in O(1), instead of rewriting the state.
468
- *
469
- * `appendFileSync` is a real append — the operating system positions the write at the end — so a note
470
- * costs one line regardless of how many lines came before it. The first version read the whole journal,
471
- * pushed, and rewrote it, which is O(n) per note and O(n^2) over a workflow.
472
- *
473
- * The bound is enforced by compaction rather than by trimming on every write: a byte counter tracks how
474
- * much has accumulated, and the file is rewritten from its tail only once it has grown past twice the
475
- * limit. That keeps the append path amortised O(1) while the file stays bounded — the same trade a
476
- * write-ahead log makes, and for the same reason.
477
- */
478
- function appendJournal(key, entry) {
479
- const file = journalFile(key)
480
- const line = `${JSON.stringify(entry)}\n`
481
- try {
482
- appendFileSync(file, line, 'utf8')
483
- journalBytes.set(file, (journalBytes.get(file) ?? 0) + Buffer.byteLength(line, 'utf8'))
484
- } catch {
485
- // A directory that does not exist yet, or a transient failure: fall back to an atomic rewrite so the
486
- // note is never silently lost.
487
- let existing = ''
488
- try { existing = existsSync(file) ? readFileSync(file, 'utf8') : '' } catch { existing = '' }
489
- writeAtomic(file, `${existing}${line}`)
490
- journalBytes.set(file, (journalBytes.get(file) ?? 0) + Buffer.byteLength(line, 'utf8'))
491
- }
492
- if ((journalBytes.get(file) ?? 0) > JOURNAL_BYTES_LIMIT) compactJournal(file)
493
- }
494
-
495
- /** Rewrite the journal from its bounded tail. Called only when the append path has grown it too far. */
496
- function compactJournal(file) {
497
- try {
498
- const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '')
499
- const tail = lines.slice(-JOURNAL_LIMIT)
500
- writeAtomic(file, tail.length === 0 ? '' : `${tail.join('\n')}\n`)
501
- journalBytes.set(file, Buffer.byteLength(tail.join('\n'), 'utf8'))
502
- } catch {
503
- journalBytes.set(file, 0)
504
- }
505
- }
506
-
507
- /** Read the journal's tail, bounded, for a human-readable status. */
508
- function readJournal(key, limit = 8) {
509
- try {
510
- const file = journalFile(key)
511
- if (!existsSync(file)) return []
512
- const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '')
513
- const out = []
514
- for (const line of lines.slice(-limit)) {
515
- try { out.push(JSON.parse(line)) } catch { /* a torn line is skipped, not fatal */ }
516
- }
517
- return out
518
- } catch {
519
- return []
520
- }
521
- }
522
-
523
- /**
524
- * Ask the human and normalise the batch.
525
- *
526
- * One item per asked question is kept, including a skipped one: "the user was asked and skipped it" is
527
- * a fact a later step must not lose, and an empty array would erase the difference between skipped and
528
- * never asked. Each option keeps its label AND its one-line trade-off — dropping the description was
529
- * how the trade-offs reached nobody.
530
- */
531
- async function askBatch(questions, exec, onAnswer) {
532
- const asked = questions.map((question, index) => ({
533
- id: typeof question.id === 'string' && question.id.trim() !== '' ? question.id : `q${index + 1}`,
534
- question: String(question.question ?? '').trim(),
535
- ...question.header === undefined ? {} : { header: String(question.header) },
536
- ...question.options === undefined ? {} : {
537
- options: question.options.map((option) => ({
538
- label: String(option.label),
539
- ...typeof option.description === 'string' && option.description !== ''
540
- ? { description: String(option.description) }
541
- : {},
542
- })),
543
- },
544
- }))
545
- for (const question of asked) {
546
- if (question.question === '') throw new Error('every question needs a "question" string')
547
- }
548
- const answer = await ask(asked, exec)
549
- const answers = (answer?.answers ?? []).map((item) => ({
550
- id: item.id,
551
- selected: Array.isArray(item.selected) ? item.selected.map(String) : [],
552
- ...item.custom === undefined ? {} : { custom: String(item.custom) },
553
- }))
554
- return onAnswer(asked, answers)
555
- }
556
-
557
- /** What an answer says, as one line a later step can be held to. */
558
- function answerLine(asked, answer) {
559
- const question = asked.find((item) => item.id === answer.id)?.question ?? answer.id
560
- const given = answer.custom !== undefined && answer.custom !== ''
561
- ? answer.custom
562
- : (answer.selected.join(', ') || '(skipped)')
563
- return `${summarize(question, 100)} -> ${summarize(given, 120)}`
564
- }
565
-
566
- // ---- start: the intent, and at most three questions about it ------------------------------------
567
-
568
- /**
569
- * `start` is the entry point, and it is also the clarify step.
570
- *
571
- * Merging them removes a round-trip that produced no information: the model had to call `start` with the
572
- * prompt and then `clarify` with the questions it could already have written. The gate is preserved —
573
- * `plan` is still refused until the intent is on record and either questions were asked or assumptions
574
- * stated.
575
- *
576
- * A clear request costs ZERO human interactions: `assumptions` records what the model took for granted
577
- * instead of spending the user's attention on a question whose answer was implied. The evidence for
578
- * "minimal, relevant questions" over interrogation is the Ask-to-Act line of work
579
- * (https://arxiv.org/abs/2504.00907).
580
- */
581
- async function start({ intent, questions, assumptions, cwd, key, reset }, exec) {
582
- if (reset === true) removeSession(root, key)
583
- // The basis is checked BEFORE the "already active" gate, and the order matters: `start` is the one
584
- // action allowed on a finished workflow, so a start with no questions and no assumptions used to
585
- // fail with "no workflow is active" — a message that describes neither the real problem nor the fix.
586
- const asking = Array.isArray(questions) && questions.length > 0
587
- const stated = trimList(assumptions, 12)
588
- if (asking && questions.length > 3) {
589
- throw new Error(
590
- 'ask at most 3 questions in one batch: minimal and relevant is the standard, and a longer ' +
591
- 'interrogation is a sign the intent is not understood yet',
592
- )
593
- }
594
- if (!asking && stated.length === 0) {
595
- throw new Error(
596
- 'start needs either "questions" (1-3, about intent, the binding constraint, and what "done" means) ' +
597
- 'or "assumptions" (what you are taking for granted if you ask nothing): a workflow may not open ' +
598
- 'with neither, because then nothing about the request has been established',
599
- )
600
- }
601
-
602
- const existing = load(key)
603
- if (existing !== null && existing.phase !== 'done') {
604
- throw new Error(
605
- `a workflow is already active for this session (phase "${existing.phase}"): ` +
606
- 'pass reset: true to discard it and start a new one, or finish it first',
607
- )
608
- }
609
- // A finished workflow's documents are archived rather than overwritten: the next workflow writes the
610
- // same file names, and "what did we decide last time" is what a fresh start wants to read.
611
- if (existing !== null) {
612
- const directory = sessionDirectory(root, key)
613
- // The stamp must be legal as a directory name on every platform. `toISOString()` is not: it contains
614
- // colons, so on Windows the rename failed and was swallowed by the catch below — the archive claimed
615
- // by the README never happened, and the next workflow silently inherited the finished one's files.
616
- const stamp = new Date().toISOString().replace(/[^0-9A-Za-z]/g, '').slice(0, 15)
617
- try {
618
- renameSync(directory, `${directory}.done-${stamp}`)
619
- } catch (error) {
620
- if (error?.code !== 'ENOENT') {
621
- // A missing directory is the benign case (nothing to archive yet). Anything else means the
622
- // previous workflow's documents are about to be mixed with this one's, so it is reported.
623
- throw new Error(`could not archive the previous workflow at ${directory}: ${error.message}`)
624
- }
625
- }
626
- invalidate(stateFile(key))
627
- }
628
-
629
- const state = emptyState(summarize(intent, INTENT_MAX))
630
- state.cwd = typeof cwd === 'string' && cwd.trim() !== '' ? cwd : null
631
- state.assumptions = stated
632
- // The user's own words are on disk before anything is asked or planned: every later artifact is a
633
- // derivative of this one, and a paraphrase — or a whitespace-collapsed copy — is not a substitute.
634
- // The verbatim text goes to the document; the collapsed form is only ever used for summaries.
635
- state.artifacts.intent = writeDocument(artifact(key, '01-intent.md'), 'Original prompt', String(intent).trim())
636
-
637
- if (!asking) {
638
- state.decisions = state.assumptions.map((item) => `assumed: ${item}`)
639
- state.phase = 'plan'
640
- record(state, 'intake', `assumptions recorded: ${state.assumptions[0]}`, new Date().toISOString())
641
- state.artifacts.intent = appendSection(
642
- state.artifacts.intent,
643
- 'Assumptions (no questions asked)',
644
- state.assumptions.map((item) => `- ${item}`).join('\n'),
645
- )
646
- save(key, state)
647
- return envelope({
648
- phase: state.phase,
649
- summary: `intent recorded with ${state.assumptions.length} assumption(s), no questions asked`,
650
- next: nextInstruction(state),
651
- files: { intent: state.artifacts.intent },
652
- note: 'the assumptions are on record and the user can correct them at the next step, which is cheaper than a question whose answer was implied',
653
- })
654
- }
655
-
656
- await askBatch(questions, exec, (asked, answers) => {
657
- state.answers = answers.map((answer) => ({ ...answer, question: asked.find((q) => q.id === answer.id)?.question }))
658
- state.decisions = [...state.decisions, ...answers.map((answer) => answerLine(asked, answer))]
659
- state.phase = 'plan'
660
- state.artifacts.intent = appendSection(
661
- state.artifacts.intent,
662
- 'Clarified intent',
663
- [
664
- '## Questions and answers',
665
- ...answers.map((answer) => {
666
- const question = asked.find((q) => q.id === answer.id)?.question ?? answer.id
667
- const given = answer.custom !== undefined && answer.custom !== ''
668
- ? answer.custom
669
- : (answer.selected.join(', ') || '(skipped)')
670
- return `- **${question}** -> ${given}`
671
- }),
672
- ].join('\n'),
673
- )
674
- record(state, 'intake', state.decisions.slice(-answers.length).join('; '), new Date().toISOString())
675
- // The single write for this transition, after the ask resolves: a failed ask leaves no half state.
676
- save(key, state)
677
- })
678
- return envelope({
679
- phase: state.phase,
680
- summary: `intent clarified with ${state.answers.length} answer(s)`,
681
- next: nextInstruction(state),
682
- files: { intent: state.artifacts.intent },
683
- decisions: state.decisions.slice(-3),
684
- note: 'the full record is in the intent file; the conversation carries the short version',
685
- })
686
- }
687
-
688
- // ---- plan: the design space, in ONE record ------------------------------------------------------
689
-
690
- /**
691
- * The design and its alternatives are one call, because they are one decision.
692
- *
693
- * `replace: true` records a revision after the user pushed back, which is the one legitimate way to
694
- * re-enter this phase. A rejected plan is a new plan rather than a loop, so the old one stays in the
695
- * document with the reason it was replaced.
696
- */
697
- function plan({ content, options, criteria, summary, recommendation, replace, key }) {
698
- const current = load(key)
699
- // A revision is the ONE legitimate re-entry into this phase, so it is checked before the gate: a plan
700
- // the user pushed back on is a new plan, not an out-of-order call.
701
- if (replace === true && current !== null && current.phase === 'choose') {
702
- // allowed: the workflow stays at `choose` and will be re-decided after the revision lands
703
- } else {
704
- assertPhase(current, 'plan')
705
- }
706
- const state = current
707
- const body = String(content ?? '').trim()
708
- if (body === '') {
709
- throw new Error(
710
- 'plan needs "content": the approach and process, as markdown — components, flow, files touched, ' +
711
- 'and how it will be verified',
712
- )
713
- }
714
- if (!Array.isArray(options) || options.length < 2) {
715
- throw new Error(
716
- 'plan needs at least two "options": "list the options and their pros and cons" is the point of the ' +
717
- 'phase, and one option is not a choice',
718
- )
719
- }
720
- const normalized = options.map((option, index) => {
721
- const id = typeof option.id === 'string' && option.id.trim() !== '' ? option.id.trim() : `o${index + 1}`
722
- const title = summarize(option.title, 160)
723
- const pros = trimList(option.pros)
724
- const cons = trimList(option.cons)
725
- if (title === '') throw new Error('every option needs a "title"')
726
- if (pros.length === 0 && cons.length === 0) {
727
- throw new Error(`option "${title}" has neither pros nor cons: an option that cannot be argued for or against is not a choice`)
728
- }
729
- return { id, title, pros, cons, criteria: summarize(option.criteria ?? '', 240), recommended: option.recommended === true }
730
- })
731
- const wanted = typeof recommendation === 'string' ? recommendation.trim() : ''
732
- const recommended = normalized.find((option) => option.recommended === true)
733
- ?? (wanted === '' ? undefined : normalized.find((option) => option.id === wanted || option.title.startsWith(wanted)))
734
- if (recommended !== undefined) recommended.recommended = true
735
-
736
- const planFile = artifact(key, '02-plan.md')
737
- const section = [
738
- '## Approach and process',
739
- body,
740
- '',
741
- '## Options and criteria',
742
- criteria === undefined || String(criteria).trim() === '' ? '' : `Criteria: ${summarize(criteria, 400)}\n`,
743
- ...normalized.map((option) => [
744
- `### ${option.id}. ${option.title}${option.recommended ? ' — recommended' : ''}`,
745
- option.criteria === '' ? '' : `- criteria: ${option.criteria}`,
746
- option.pros.length > 0 ? `- pros: ${option.pros.join('; ')}` : '- pros: (none recorded)',
747
- option.cons.length > 0 ? `- cons: ${option.cons.join('; ')}` : '- cons: (none recorded)',
748
- ].filter((part) => part !== '').join('\n')),
749
- ].join('\n')
750
- state.artifacts.plan = replace === true
751
- ? appendSection(planFile, 'Plan (revised)', `Reason for revision: ${summarize(summary ?? 'user feedback', 300)}\n\n${section}`)
752
- : writeDocument(planFile, 'Plan', section)
753
-
754
- state.options = normalized
755
- state.plan = bulletsOf(body)
756
- state.criteria = summarize(criteria ?? '', 400)
757
- if (wanted !== '') state.recommendation = summarize(wanted, 240)
758
-
759
- const line = summarize(summary ?? `${normalized.length} options: ${normalized.map((o) => o.title).join(' / ')}`, 200)
760
- state.phase = 'choose'
761
- record(state, replace === true ? 'plan-revised' : 'plan', line, new Date().toISOString())
762
- save(key, state)
763
- return envelope({
764
- phase: state.phase,
765
- summary: line,
766
- next: nextInstruction(state),
767
- files: { plan: state.artifacts.plan },
768
- // The alternatives are echoed back here and ONLY here: the next call asks the user to choose between
769
- // them, so a caller that has not read the plan file would otherwise re-read it just to name them.
770
- // The prose stays in the file — echoing it on every later call would be a cost with no reader.
771
- options: normalized.map((option) => ({
772
- id: option.id,
773
- title: option.title,
774
- recommended: option.recommended === true,
775
- })),
776
- note: `${body.length} chars stored on disk; the trade-offs and criteria are in the plan file`,
777
- })
778
- }
779
-
780
- // ---- choose: the user decides ------------------------------------------------------------------
781
-
782
- async function choose({ question, key }, exec) {
783
- const state = assertPhase(load(key), 'choose')
784
- const listed = Array.isArray(state.options) ? state.options : []
785
- if (listed.length === 0) throw new Error('no options are recorded, so there is nothing to choose between')
786
- // The recommendation goes first, which is the order the ask-user contract reads as "recommended".
787
- const ordered = [...listed].sort((a, b) => Number(b.recommended === true) - Number(a.recommended === true))
788
- const asked = typeof question === 'string' && question.trim() !== ''
789
- ? question.trim()
790
- : `Which option should I execute?${state.criteria === '' ? '' : ` (${summarize(state.criteria, 160)})`}`
791
-
792
- const chosen = await askBatch(
793
- [{
794
- id: 'choice',
795
- header: 'Choose',
796
- question: asked,
797
- options: ordered.map((option) => ({
798
- label: option.recommended === true ? `${option.title} (Recommended)` : option.title,
799
- ...option.cons.length > 0
800
- ? { description: `cost: ${option.cons.slice(0, 2).join('; ')}` }
801
- : (option.pros.length > 0 ? { description: `gain: ${option.pros.slice(0, 2).join('; ')}` } : {}),
802
- })),
803
- }],
804
- exec,
805
- (askedQuestions, answers) => {
806
- const answer = answers[0] ?? { id: 'choice', selected: [] }
807
- const spoken = answer.custom !== undefined && answer.custom !== ''
808
- ? answer.custom
809
- : (answer.selected[0] ?? '')
810
- const matched = listed.find((option) => spoken === option.title || spoken.startsWith(option.title))
811
- const decision = summarize(matched?.title ?? spoken, 200)
812
- if (decision === '') {
813
- throw new Error('the user did not choose an option, so run cannot be entered: ask again with a clearer question')
814
- }
815
- state.choice = {
816
- id: matched?.id ?? null,
817
- title: decision,
818
- verbatim: spoken,
819
- options: listed.map((option) => option.title),
820
- }
821
- state.decisions = [...state.decisions, `chosen option: ${summarize(decision, 120)}`]
822
- appendSection(
823
- artifact(key, '02-plan.md'),
824
- 'Decision',
825
- [
826
- `The user chose **${decision}**${askedQuestions[0] === undefined ? '' : ` in answer to: ${askedQuestions[0].question}`}`,
827
- '',
828
- matched === undefined
829
- ? 'This was not one of the listed options; it is recorded verbatim.'
830
- : `Alternatives offered: ${listed.map((option) => option.title).join(' / ')}`,
831
- ].join('\n'),
832
- )
833
- state.phase = 'run'
834
- record(state, 'choose', decision, new Date().toISOString())
835
- save(key, state)
836
- return decision
837
- },
838
- )
839
- return envelope({
840
- phase: state.phase,
841
- summary: `user chose: ${summarize(chosen, 140)}`,
842
- next: nextInstruction(state),
843
- decisions: state.decisions.slice(-3),
844
- note: 'execute only this option; a different option is a new workflow',
845
- })
846
- }
847
-
848
- // ---- run: execute, and close it in the same call when one step finishes it -----------------------
849
-
850
- /**
851
- * `run` opens execution, records progress, and closes the workflow.
852
- *
853
- * One action rather than `execute` + `progress` + `finish`, because the three differ only in whether
854
- * the work is opening, continuing, or over — and the caller knows which from what it passes. The
855
- * common case (one step, done) is one call instead of three.
856
- *
857
- * Every mutation happens in memory and is written once, so a run that records its steps and its
858
- * outcome costs two writes: the execution document and one state document.
859
- */
860
- function run({ goal, steps, outcome, files, evidence, constraints, status, note, key }) {
861
- const state = load(key)
862
- if (state === null) {
863
- throw new Error('no workflow is active for this session: call action "start" and then "plan" and "choose" first')
864
- }
865
- // A finished workflow is terminal for NEW work, but its journal may still be written to: a note or a
866
- // late step from a resumed session is a record-keeping call, not a new execution. So the gate is
867
- // applied to what the call asks for rather than to the phase alone — the first version refused
868
- // everything after `done`, including a note that only appended to the journal.
869
- const finishing = typeof outcome === 'string' && outcome.trim() !== ''
870
- // WHICH CALL OPENS EXECUTION is decided by whether an execution document exists, not by the phase:
871
- // `choose` sets the phase to `run`, so a phase test would call every later step "the opening one" and
872
- // let a bare `run` through with nothing to record.
873
- const opening = state.artifacts.execution === undefined
874
- if (state.phase === 'done') {
875
- if (finishing) {
876
- throw new Error(
877
- 'this workflow is already done, so it cannot take another outcome: a new unit of work needs its ' +
878
- 'own workflow — call action "start"',
879
- )
880
- }
881
- if (status === undefined) {
882
- throw new Error(
883
- 'this workflow is already done: a new unit of work needs its own workflow (action "start"); pass ' +
884
- '"status" and "note" only to record a late step in the finished one',
885
- )
886
- }
887
- } else if (state.phase === 'intake' || state.phase === 'plan') {
888
- // The refusal is the gate: execution cannot begin before the user has chosen.
889
- assertPhase(state, 'choose')
890
- } else {
891
- assertPhase(state, 'run')
892
- }
893
- if (state.choice === undefined) {
894
- throw new Error(
895
- 'run without a recorded choice is refused: the user has not picked an option yet, so call action "plan" ' +
896
- 'and then "choose"',
897
- )
898
- }
899
- // Opening execution without a plan of what it will do is how an execution record becomes a note.
900
- if (opening && steps === undefined && outcome === undefined) {
901
- throw new Error(
902
- 'the first "run" needs "steps" (what execution will do) or "outcome" (work this single call ' +
903
- 'finishes): an execution record with neither says nothing',
904
- )
905
- }
906
-
907
- const now = new Date().toISOString()
908
- const plan = trimList(steps)
909
- if (plan.length > 0) state.steps = plan.slice(0, STEP_LIMIT).map((text) => ({ text, status: 'pending' }))
910
- if (typeof goal === 'string' && goal.trim() !== '') state.goal = summarize(goal, 400)
911
- if (Array.isArray(files)) state.files = trimList(files)
912
- if (Array.isArray(constraints)) state.constraints = trimList(constraints)
913
- if (typeof evidence === 'string' && evidence.trim() !== '') {
914
- state.evidence = [...state.evidence, summarize(evidence, 240)].slice(-STEP_LIMIT)
915
- }
916
-
917
- let executionFile = state.artifacts.execution
918
- if (executionFile === undefined) {
919
- executionFile = writeDocument(
920
- artifact(key, '03-execution.md'),
921
- `Execution: ${state.choice.title}`,
922
- [
923
- `Chosen option: **${state.choice.title}**`,
924
- state.goal === undefined ? '' : `\n## Goal\n${state.goal}`,
925
- state.steps.length === 0 ? '' : `\n## Steps\n${state.steps.map((step, index) => `${index + 1}. ${step.text}`).join('\n')}`,
926
- state.files === undefined || state.files.length === 0 ? '' : `\n## Files in scope\n${state.files.map((f) => `- ${f}`).join('\n')}`,
927
- state.constraints === undefined || state.constraints.length === 0 ? '' : `\n## Constraints\n${state.constraints.map((c) => `- ${c}`).join('\n')}`,
928
- ].filter((part) => part !== '').join('\n'),
929
- )
930
- state.artifacts.execution = executionFile
931
- }
932
-
933
- // `status` + `note` is the multi-step form: it records one step without closing anything.
934
- const stepText = status === undefined ? '' : summarize(note ?? '', 200)
935
- if (stepText !== '') {
936
- const done = status !== 'pending'
937
- const existing = state.steps.find((item) => item.text === stepText)
938
- if (existing !== undefined) existing.status = done ? 'done' : 'pending'
939
- else state.steps.push({ text: stepText, status: done ? 'done' : 'pending' })
940
- if (state.steps.length > STEP_LIMIT) state.steps.splice(0, state.steps.length - STEP_LIMIT)
941
- appendJournal(key, { at: now, kind: 'step', text: stepText, done })
942
- } else if (status !== undefined) {
943
- throw new Error('run with "status" also needs "note": the step it is marking')
944
- }
945
-
946
- // Only a workflow in flight can be opened. A `done` one may take the late step recorded above, but the
947
- // phase must not move backwards: the first version fell through and set it to `run` again, so a
948
- // finished workflow reported itself as executing.
949
- if (state.phase === 'done') {
950
- save(key, state)
951
- return envelope({
952
- phase: 'done',
953
- summary: `late step recorded: ${stepText}`,
954
- next: null,
955
- steps: state.steps.map((item) => `${item.status === 'done' ? '[x]' : '[ ]'} ${item.text}`).slice(0, 8),
956
- note: 'the workflow is finished; this was recorded in its journal without reopening it',
957
- })
958
- }
959
-
960
- if (finishing) {
961
- const open = state.steps.filter((item) => item.status !== 'done')
962
- state.outcome = summarize(outcome, 400)
963
- state.artifacts.outcome = appendSection(
964
- artifact(key, '04-outcome.md'),
965
- 'Outcome',
966
- `${state.outcome}${open.length === 0 ? '' : `\n\nStill open: ${open.map((item) => item.text).join('; ')}`}`,
967
- now,
968
- )
969
- state.phase = 'done'
970
- record(state, 'run', state.outcome, now)
971
- save(key, state)
972
- return envelope({
973
- phase: state.phase,
974
- summary: state.outcome,
975
- next: null,
976
- files: { outcome: state.artifacts.outcome, ...executionFile === undefined ? {} : { execution: executionFile } },
977
- // The final result lists only what is NOT finished: a closed step's text has no reader left, and
978
- // this is the last message of the workflow, so it is the one place where that omission is free.
979
- steps: open.map((item) => `[ ] ${item.text}`).slice(0, 8),
980
- note: open.length === 0
981
- ? 'every recorded step is closed: the workflow is finished'
982
- : `still open: ${open.length} step(s) — reported as such rather than hidden`,
983
- })
984
- }
985
-
986
- state.phase = 'run'
987
- record(state, 'run', `execution started on: ${summarize(state.choice.title, 120)}`, now)
988
- save(key, state)
989
- return envelope({
990
- phase: state.phase,
991
- summary: `executing: ${summarize(state.choice.title, 120)}`,
992
- next: nextInstruction(state),
993
- files: { execution: executionFile },
994
- // `steps` is echoed here and on progress, and nowhere else: this result is the plan the model works
995
- // from, and repeating it on every later call would grow the request history for no reader.
996
- steps: state.steps.map((item) => `${item.status === 'done' ? '[x]' : '[ ]'} ${item.text}`).slice(0, 8),
997
- note: 'record a step with status + note, a decision with action "note", and close it with "outcome"',
998
- })
999
- }
1000
-
1001
- /**
1002
- * `note` records a remark, or a decision, made during execution.
1003
- *
1004
- * It appends one journal line and does not rewrite `state.json` for a plain note, which is what makes
1005
- * it cheap enough to use freely: the previous shape re-read and rewrote the whole state for every
1006
- * entry, which is O(n^2) in the number of entries. A decision IS written back, because it changes what
1007
- * `status` and the prompt report.
1008
- */
1009
- function note({ text, kind, key }) {
1010
- const state = load(key)
1011
- if (state === null) throw new Error('no workflow is active for this session')
1012
- if (state.phase !== 'run' && state.phase !== 'done') {
1013
- throw new Error(`"note" belongs to execution, but this workflow is at "${state.phase}"`)
1014
- }
1015
- const body = summarize(text, 400)
1016
- if (body === '') throw new Error('note needs "text": what is worth remembering about this step')
1017
- const decision = kind === 'decision'
1018
- appendJournal(key, { at: new Date().toISOString(), kind: decision ? 'decision' : 'note', text: body })
1019
- state.notes = [...state.notes, body].slice(-STEP_LIMIT)
1020
- state.updatedAt = new Date().toISOString()
1021
- if (decision) {
1022
- state.decisions = [...state.decisions, body].slice(-STEP_LIMIT)
1023
- save(key, state)
1024
- }
1025
- return envelope({
1026
- phase: state.phase,
1027
- summary: body,
1028
- next: nextInstruction(state),
1029
- note: decision
1030
- ? 'recorded as a decision: it is carried in the state, not only in the journal'
1031
- : 'recorded in the journal for this session',
1032
- })
1033
- }
1034
-
1035
- // ---- checkpoint --------------------------------------------------------------------------------
1036
-
1037
- /**
1038
- * The compression, made explicit and inspectable.
1039
- *
1040
- * Everything decided so far goes to `05-checkpoint.md`, and the response carries one line — the shape a
1041
- * compacted or resumed session needs, and nothing more. The fold also merges the journal into the
1042
- * state, which is the moment a note's own size is all it ever cost.
1043
- */
1044
- function checkpoint({ key, reason }) {
1045
- const state = load(key)
1046
- if (state === null) throw new Error('no workflow is active for this session, so there is nothing to checkpoint')
1047
- const now = new Date().toISOString()
1048
- const journal = readJournal(key, JOURNAL_LIMIT)
1049
- const body = [
1050
- `Phase: ${state.phase}`,
1051
- `- original prompt: ${state.intent}`,
1052
- `- cwd: ${state.cwd ?? '(unknown)'}`,
1053
- ...state.assumptions.length === 0 ? [] : [`- assumptions: ${state.assumptions.join('; ')}`],
1054
- ...state.decisions.length === 0 ? [] : ['', '## Decisions', ...state.decisions.map((d) => `- ${d}`)],
1055
- ...state.choice === undefined ? [] : ['', '## Chosen option', `- ${state.choice.title}`],
1056
- ...state.steps.length === 0 ? [] : ['', '## Steps', ...state.steps.map((s) => `- [${s.status === 'done' ? 'x' : ' '}] ${s.text}`)],
1057
- ...state.evidence.length === 0 ? [] : ['', '## Evidence', ...state.evidence.map((e) => `- ${e}`)],
1058
- ...journal.length === 0 ? [] : ['', '## Recent activity', ...journal.map((entry) => `- ${entry.text}`)],
1059
- ...Object.keys(state.artifacts).length === 0 ? [] : ['', '## Artifacts', ...Object.entries(state.artifacts).map(([name, file]) => `- ${name}: ${file}`)],
1060
- ...typeof reason === 'string' && reason.trim() !== '' ? ['', `Reason: ${summarize(reason, 400)}`] : [],
1061
- ].join('\n')
1062
- state.artifacts.checkpoint = appendSection(artifact(key, '05-checkpoint.md'), 'Checkpoint', body, now)
1063
- state.checkpoints = [...state.checkpoints, { at: now, phase: state.phase, reason: summarize(reason ?? '', 160) }].slice(-STEP_LIMIT)
1064
- state.updatedAt = now
1065
- save(key, state)
1066
- return envelope({
1067
- phase: state.phase,
1068
- summary: `checkpoint at ${state.phase}: ${state.history.length} history entries compressed`,
1069
- next: nextInstruction(state),
1070
- files: { checkpoint: state.artifacts.checkpoint },
1071
- decisions: state.decisions.slice(-3),
1072
- note: compact(state),
1073
- })
1074
- }
1075
-
1076
- // ---- status / reset ----------------------------------------------------------------------------
1077
-
1078
- function status({ key }) {
1079
- const state = load(key)
1080
- if (state === null) {
1081
- return envelope({
1082
- phase: 'none',
1083
- summary: 'no workflow is active for this session',
1084
- next: 'start one with the user\'s own prompt when the request is more than a single obvious step',
1085
- })
1086
- }
1087
- return envelope({
1088
- phase: state.phase,
1089
- summary: state.outcome ?? state.goal ?? state.intent,
1090
- next: nextInstruction(state),
1091
- files: state.artifacts,
1092
- decisions: state.decisions.slice(-3),
1093
- steps: state.steps.map((item) => `${item.status === 'done' ? '[x]' : '[ ]'} ${item.text}`).slice(0, 8),
1094
- note: `updated ${state.updatedAt}`,
1095
- })
1096
- }
1097
-
1098
- function reset({ key }) {
1099
- const state = load(key)
1100
- if (state === null) {
1101
- return envelope({ phase: 'none', summary: 'no workflow was active', next: nextInstruction({ phase: 'intake' }) })
1102
- }
1103
- removeSession(root, key)
1104
- logger({ phase: 'reset', session: key })
1105
- return envelope({
1106
- phase: 'none',
1107
- summary: `workflow discarded (was at "${state.phase}")`,
1108
- next: 'call workflow_plan with action "start" and the user\'s own prompt to begin again',
1109
- })
1110
- }
1111
-
1112
- return { start, plan, choose, run, note, checkpoint, status, reset }
1113
- }
1114
-
1115
- /**
1116
- * Delete one session's directory, after proving it is inside the root.
1117
- *
1118
- * Same lesson as this repository's protected-path boundary: a join is not a containment check, and a
1119
- * session key that reached the filesystem unchecked would be a path traversal. The check is on the path,
1120
- * and it fails closed.
1121
- */
1122
- export function removeSession(root, key) {
1123
- const directory = sessionDirectory(root, key)
1124
- const normalizedRoot = root.endsWith('/') || root.endsWith('\\') ? root.slice(0, -1) : root
1125
- const separator = process.platform === 'win32' ? '\\' : '/'
1126
- if (directory !== normalizedRoot && !directory.startsWith(`${normalizedRoot}${separator}`)) {
1127
- throw new Error(`refusing to remove '${directory}': it is not inside '${root}'`)
1128
- }
1129
- rmSync(directory, { recursive: true, force: true })
1130
- invalidate(statePathOf(root, key))
1131
- journalBytes.delete(journalPathOf(root, key))
1132
- }
1133
-
1134
- // ---------------------------------------------------------------------------
1135
- // the prompt section
1136
- // ---------------------------------------------------------------------------
1137
-
1138
- /**
1139
- * Placement of the section, resolved defensively.
1140
- *
1141
- * `getSectionOrder` takes a name from a centrally owned table, so an unknown name may be a throw or an
1142
- * `undefined` — and an `undefined` order would put `NaN` into the sort and could reorder the whole
1143
- * prompt. Falling back to a fixed number keeps an unrecognised name harmless.
1144
- */
1145
- export function sectionOrder(prompt) {
1146
- try {
1147
- const order = prompt?.getSectionOrder?.('TOOL_READ')
1148
- return Number.isFinite(order) ? order + 1 : 500
1149
- } catch {
1150
- return 500
1151
- }
1152
- }
1153
-
1154
- /**
1155
- * The plugin's contribution to the system prompt. It is a CONSTANT, and that is the whole design.
1156
- *
1157
- * WHY THIS TEXT MUST NOT DEPEND ON THE WORKFLOW STATE
1158
- *
1159
- * DeepSeek's context cache matches complete prefix units byte for byte, and the system prompt sits before
1160
- * the tool catalog and the whole message history — so a change to it invalidates everything that follows.
1161
- * Measured on the live endpoint, ten variants of one 7,384-token request: appending a message kept a 98.7%
1162
- * hit, while changing the system prompt ANYWHERE — one line at the end included — produced a 0.0% hit
1163
- * (https://github.com/diguike/book-deepseek-harness/blob/main/book/11-prefix-stability.md).
1164
- *
1165
- * The first version of this plugin rendered the current phase into this text, so a four-call workflow
1166
- * re-wrote the system prompt 3-4 times and paid a full-prefix miss each time. The harness itself hit the
1167
- * same trap with the sandbox policy: one permission switch dropped cache reads from ~14.7k tokens to 256,
1168
- * and the fix was to move the changing fact out of the prompt entirely
1169
- * (https://github.com/deepseek-ai/deepseek-harness/blob/master/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md).
1170
- *
1171
- * So the phase is NOT here. The single next call is returned in the `workflow_plan` tool's own result, which is
1172
- * appended history and therefore free — and a call made out of order is refused with the phase named. The
1173
- * prompt only has to say that the tool exists and when to reach for it.
1174
- *
1175
- * `tests/workflow.test.mjs` asserts the invariant directly: this string is byte-identical at every phase,
1176
- * so the plugin induces zero prompt changes per workflow. Under the harness's own README taxonomy
1177
- * (`#### KV Cache effect`) this plugin is **prefix-stable**, with one named exception: the text changes
1178
- * once, at registration, if a deployment edits it.
1179
- */
1180
- export const SECTION_TEXT = [
1181
- '## Workflow',
1182
- 'For a request that is more than one obvious step, or whose shape you would have to guess, use the',
1183
- '`workflow_plan` tool: it records the request, asks the user what they actually want, lays out the alternatives',
1184
- 'with their pros and cons, has the user choose, and only then executes. Its own results carry what to do',
1185
- 'next, and a step taken out of order is refused with the phase that must run first. For a single obvious',
1186
- 'step, skip it.',
1187
- ].join(' ')
1188
-
1189
- /** The section text. A function of nothing, deliberately: see SECTION_TEXT. */
1190
- export function workflowSectionText() {
1191
- return SECTION_TEXT
1192
- }
1193
-
1194
- // ---------------------------------------------------------------------------
1195
- // plugin
1196
- // ---------------------------------------------------------------------------
1197
-
1198
- /**
1199
- * The tool description.
1200
- *
1201
- * This is now the ONLY discovery path, because the prompt section is a frozen constant and cannot carry
1202
- * guidance. Two consequences it has to honour, and the first version of this text failed the first one:
1203
- *
1204
- * 1. it must NAME the tool. The earlier text said "Four calls:" and then listed `start` / `plan` /
1205
- * `choose` / `run`, which reads as four tool names — a model following it would call tools that do not
1206
- * exist. Caught by verifying the packed tarball rather than the checkout.
1207
- * 2. every call in the shortest path is spelled as `workflow_plan(action: "...")`, so the model never has
1208
- * to guess how the pieces fit together. The per-phase guidance is deliberately not here: it travels in
1209
- * each result's `next` field, which is appended history and therefore costs the cache nothing.
1210
- *
1211
- * It lives in the tool catalog, inside the cached prefix, sent once and unchanged for the session.
1212
- */
1213
- const TOOL_DESCRIPTION = [
1214
- 'workflow_plan: agree on what is wanted and how it will be done, before doing it. Use it when a request is',
1215
- 'more than one obvious step, or when you would have to guess the user\'s intent, their preferred approach,',
1216
- 'or where the work belongs. Call it four times:',
1217
- '1. workflow_plan(action: "start") — the user\'s prompt verbatim, plus 1-3 questions about intent OR the',
1218
- 'assumptions you are making.',
1219
- '2. workflow_plan(action: "plan") — the approach and process, at least two alternatives with pros and cons,',
1220
- 'and which one you recommend.',
1221
- '3. workflow_plan(action: "choose") — ASKS THE USER; their answer is recorded and you cannot answer it for them.',
1222
- '4. workflow_plan(action: "run") — execute the chosen option; add "outcome" to close the workflow in this call.',
1223
- 'Each result carries "next", the one call to make. Then workflow_plan(action: "note") for a decision, or',
1224
- '"checkpoint" at a boundary. Long documents go to files and only a line comes back, so the conversation stays',
1225
- 'small; results list the files. A step out of order is refused, and the refusal names the phase to record',
1226
- 'first. Also available: "status", "reset".',
1227
- ].join(' ')
1228
-
1229
- const QUESTION_SCHEMA = {
1230
- type: 'object',
1231
- additionalProperties: false,
1232
- properties: {
1233
- id: { type: 'string', description: 'Stable id, echoed in the answer. Defaults to q1, q2, ...' },
1234
- header: { type: 'string', description: 'Short heading, e.g. "Intent" or "Constraint".' },
1235
- question: { type: 'string', description: 'The question itself, in the user\'s own terms.' },
1236
- options: {
1237
- type: 'array',
1238
- description: 'Optional choices to offer. If you recommend one, put it first and append " (Recommended)".',
1239
- items: {
1240
- type: 'object',
1241
- additionalProperties: false,
1242
- properties: {
1243
- label: { type: 'string', description: 'Short user-facing label.' },
1244
- description: { type: 'string', description: 'One sentence on the trade-off.' },
1245
- },
1246
- required: ['label'],
1247
- },
1248
- },
1249
- },
1250
- required: ['question'],
1251
- }
1252
-
1253
- const OPTION_SCHEMA = {
1254
- type: 'object',
1255
- additionalProperties: false,
1256
- properties: {
1257
- id: { type: 'string', description: 'Short id, e.g. o1. Defaults to the position.' },
1258
- title: { type: 'string', description: 'The approach, named in a way the user will recognise.' },
1259
- pros: { type: 'array', items: { type: 'string' }, description: 'What this approach buys.' },
1260
- cons: { type: 'array', items: { type: 'string' }, description: 'What it costs, including what it rules out.' },
1261
- criteria: { type: 'string', description: 'The criterion this option does best or worst on.' },
1262
- recommended: { type: 'boolean', description: 'Set on exactly one option to put it first for the user.' },
1263
- },
1264
- required: ['title'],
1265
- }
1266
-
1267
- /** The action list, in the order the description explains them. */
1268
- export const ACTIONS = ['start', 'plan', 'choose', 'run', 'note', 'checkpoint', 'status', 'reset']
1269
-
1270
- /**
1271
- * The cost model, asserted by the tests.
1272
- *
1273
- * It exists as data rather than prose because the whole point of the fused phases is a number. `toolCalls`
1274
- * and `humanInteractions` are the round-trips; `promptChangesPerWorkflow` is the cache property, and it is
1275
- * zero: the system prompt is a constant, so no phase of any workflow can invalidate the provider's cached
1276
- * prefix. A change that reintroduces a phase, or that lets state reach the prompt, has to change this
1277
- * object and therefore fail a test.
1278
- */
1279
- export const COST_MODEL = {
1280
- shortestPath: ['start', 'plan', 'choose', 'run'],
1281
- toolCalls: 4,
1282
- humanInteractions: 1,
1283
- // The number of times the plugin's system-prompt contribution changes over one whole workflow. Not
1284
- // "small" — zero, because anything else costs the entire prefix.
1285
- promptChangesPerWorkflow: 0,
1286
- cacheEffect: 'prefix-stable',
1287
- previous: { toolCalls: 8, humanInteractions: 2, promptChangesPerWorkflow: 3 },
1288
- }
1289
-
1290
- /**
1291
- * The model-facing tool name.
1292
- *
1293
- * NOT `workflow`. A host composition already exposes a built-in tool by that name — the JavaScript
1294
- * multi-agent orchestrator from `@deepseek-ai/dsh-tool-workflow` — and two tools sharing one name within a
1295
- * scope collide: whoever registers second aborts, or both resolve and the model is offered two unrelated
1296
- * `workflow` tools. `_plan` also says what this one is: the planning half of a workflow, leaving
1297
- * `workflow` for the orchestrator.
1298
- */
1299
- export const TOOL_NAME = 'workflow_plan'
1300
-
1301
- /** Exported so a test can assert the tool contract without a live registry. */
1302
- export function workflowToolDefinition(handlers) {
1303
- return {
1304
- name: TOOL_NAME,
1305
- description: TOOL_DESCRIPTION,
1306
- parameters: {
1307
- type: 'object',
1308
- properties: {
1309
- action: { type: 'string', enum: ACTIONS, description: 'The phase to record. A later phase is refused until the earlier one is recorded.' },
1310
- intent: { type: 'string', description: 'start: the user\'s request, verbatim. Required by start.' },
1311
- questions: { type: 'array', items: QUESTION_SCHEMA, description: 'start: 1-3 questions for the user about intent. Give this or "assumptions".' },
1312
- assumptions: { type: 'array', items: { type: 'string' }, description: 'start: what you are taking for granted, when the request is clear enough to ask nothing.' },
1313
- cwd: { type: 'string', description: 'start: absolute path the work happens in. Defaults to the session working directory.' },
1314
- reset: { type: 'boolean', description: 'start: discard an active workflow for this session and begin a new one.' },
1315
- content: { type: 'string', description: 'plan: the approach and process, as markdown. Required by plan.' },
1316
- options: { type: 'array', items: OPTION_SCHEMA, description: 'plan: at least two alternatives with pros and cons. Required by plan.' },
1317
- criteria: { type: 'string', description: 'plan: the criteria the alternatives were judged against.' },
1318
- recommendation: { type: 'string', description: 'plan: the id or title of the option you recommend.' },
1319
- summary: { type: 'string', description: 'plan: the one line this phase contributes to later context.' },
1320
- replace: { type: 'boolean', description: 'plan: record a revision after the user rejected the plan, keeping the original.' },
1321
- question: { type: 'string', description: 'choose: the question shown to the user. Defaults to asking which option to run.' },
1322
- goal: { type: 'string', description: 'run: what this execution must achieve.' },
1323
- steps: { type: 'array', items: { type: 'string' }, description: 'run: the ordered steps.' },
1324
- outcome: { type: 'string', description: 'run: what was delivered. Passing it closes the workflow in the same call.' },
1325
- files: { type: 'array', items: { type: 'string' }, description: 'run: files in scope.' },
1326
- evidence: { type: 'string', description: 'run: how the work will be, or was, verified.' },
1327
- constraints: { type: 'array', items: { type: 'string' }, description: 'run: what must not change.' },
1328
- status: { type: 'string', enum: ['done', 'pending'], description: 'run: with "note", marks that step done or leaves it open.' },
1329
- note: { type: 'string', description: 'run: the step being marked. In "note", the remark to record.' },
1330
- text: { type: 'string', description: 'note: the decision or remark to record.' },
1331
- kind: { type: 'string', enum: ['note', 'decision'], description: 'note: a decision is carried in the state; a note stays in the journal.' },
1332
- reason: { type: 'string', description: 'checkpoint: why the compression is happening now.' },
1333
- },
1334
- required: ['action'],
1335
- },
1336
- output: {
1337
- schema: { type: 'string' },
1338
- render: (_args, value) => [{ type: 'text', text: value }],
1339
- },
1340
- async execute(args, exec) {
1341
- const key = sessionKeyOfExecution(exec)
1342
- const action = String(args.action ?? '').trim()
1343
- let value
1344
- switch (action) {
1345
- case 'start':
1346
- if (typeof args.intent !== 'string' || args.intent.trim() === '') {
1347
- throw new Error('start needs "intent": the user\'s request in their own words, not a paraphrase of it')
1348
- }
1349
- value = await handlers.start({
1350
- intent: args.intent,
1351
- questions: args.questions,
1352
- assumptions: args.assumptions,
1353
- cwd: args.cwd ?? exec?.agent?.session?.header?.cwd,
1354
- key,
1355
- reset: args.reset === true,
1356
- }, exec)
1357
- break
1358
- case 'plan':
1359
- value = handlers.plan({
1360
- content: args.content,
1361
- options: args.options,
1362
- criteria: args.criteria,
1363
- summary: args.summary,
1364
- recommendation: args.recommendation,
1365
- replace: args.replace === true,
1366
- key,
1367
- })
1368
- break
1369
- case 'choose':
1370
- value = await handlers.choose({ question: args.question, key }, exec)
1371
- break
1372
- case 'run':
1373
- value = handlers.run({
1374
- goal: args.goal,
1375
- steps: args.steps,
1376
- outcome: args.outcome,
1377
- files: args.files,
1378
- evidence: args.evidence,
1379
- constraints: args.constraints,
1380
- status: args.status,
1381
- note: args.note ?? args.text,
1382
- key,
1383
- })
1384
- break
1385
- case 'note':
1386
- value = handlers.note({ text: args.text ?? args.note, kind: args.kind, key })
1387
- break
1388
- case 'checkpoint':
1389
- value = handlers.checkpoint({ key, reason: args.reason })
1390
- break
1391
- case 'status':
1392
- value = handlers.status({ key })
1393
- break
1394
- case 'reset':
1395
- value = handlers.reset({ key })
1396
- break
1397
- default:
1398
- throw new Error(`unknown workflow action '${action}': expected one of ${ACTIONS.join(', ')}`)
1399
- }
1400
- return JSON.stringify(value)
1401
- },
1402
- // A workflow step rewrites one session's own documents; two of them touching the same state would
1403
- // interleave their appends.
1404
- isConcurrencySafe: () => false,
1405
- }
1406
- }
1407
-
1408
- export function apply(ctx, config) {
1409
- // `config ?? {}` rather than a `config = {}` default parameter, because the two are NOT equivalent:
1410
- // a default parameter only fires for `undefined`, and Cordis hands the row's config through as `null`
1411
- // when the row declares none. The shipped 0.1.5 read `config.workflowRoot` off that null and the row
1412
- // failed to activate with "Cannot read properties of null".
1413
- const resolved = config ?? {}
1414
- const root = typeof resolved.workflowRoot === 'string' && resolved.workflowRoot.trim() !== ''
1415
- ? resolved.workflowRoot
1416
- : workflowRoot()
1417
- const autoCompact = resolved.autoCompact !== false
1418
- const maxCheckpoints = Number.isInteger(resolved.maxCheckpoints) && resolved.maxCheckpoints > 0 ? resolved.maxCheckpoints : 20
1419
-
1420
- // The engine's own automatic compaction is named in the checkpoint result rather than invoked: this
1421
- // plugin holds no session handle, and a call into a service whose context contract is not published
1422
- // would be a guess dressed up as a feature. What it does instead is deterministic — the detail leaves
1423
- // the conversation for a file, and the boundary is recorded where a compaction can see it.
1424
- const compact = (state) => {
1425
- const boundaries = state.checkpoints.length
1426
- if (!autoCompact) {
1427
- return `${boundaries} checkpoint(s) recorded; automatic compaction is disabled by configuration`
1428
- }
1429
- return `${boundaries} checkpoint(s) recorded (limit ${maxCheckpoints}); the full record is on disk, so the conversation can drop it and keep the summary`
1430
- }
1431
-
1432
- const handlers = makeHandlers({
1433
- root,
1434
- compact,
1435
- logger: () => { /* reserved for a future activity feed; kept so the seam exists in one place */ },
1436
- ask: async (questions, exec) => {
1437
- const userQuestions = ctx.userQuestions
1438
- if (userQuestions === undefined) {
1439
- throw new Error('the workflow_plan tool needs the userQuestions service to ask the user, and this deployment has none')
1440
- }
1441
- const request = {
1442
- questions,
1443
- ...exec?.agent === undefined ? {} : { agent: exec.agent },
1444
- ...exec?.signal === undefined ? {} : { signal: exec.signal },
1445
- }
1446
- // `wait.callId` is what keeps the question answerable in the transcript after the call returns,
1447
- // and it exists only for a real tool call.
1448
- return userQuestions.ask(exec?.callId === undefined ? request : { ...request, wait: { callId: exec.callId } })
1449
- },
1450
- })
1451
-
1452
- if (typeof ctx.tools?.register === 'function') ctx.tools.register(workflowToolDefinition(handlers))
1453
-
1454
- // The prompt half is registered through `ctx.inject`, NOT by reading `ctx.systemPrompt` directly.
1455
- //
1456
- // Reading it directly is what broke the shipped 0.1.5: Cordis resolves a service property through an
1457
- // enforcing proxy, so an undeclared service does not read as `undefined` — it THROWS
1458
- // Error: cannot get property "systemPrompt" without inject
1459
- // and a guard of the form `typeof ctx.systemPrompt?.section === 'function'` never gets to run, because
1460
- // the property access itself is the throw. The row then failed to activate entirely, losing the tool
1461
- // as well as the section.
1462
- //
1463
- // `ctx.inject` is the first-party pattern for an optional service (`dsh-tool-fs` registers its image
1464
- // tool the same way): the callback runs when the service is present and unwinds when it goes away, and
1465
- // a deployment without a system prompt keeps the tool instead of losing everything.
1466
- //
1467
- // The text is a constant: no state is read, nothing is looked up, and the model's cached prefix
1468
- // survives every phase of every workflow. See SECTION_TEXT.
1469
- if (typeof ctx.inject === 'function') {
1470
- ctx.inject(['systemPrompt'], (scoped) => {
1471
- scoped.systemPrompt.section({
1472
- name: 'dsh-os:workflow',
1473
- order: sectionOrder(scoped.systemPrompt),
1474
- text: SECTION_TEXT,
1475
- })
1476
- })
1477
- }
1478
-
1479
- return handlers
1480
- }
1481
-
1482
- /** Exported for tests, so the state machine can be exercised without a live plugin context. */
1483
- export const __internals = {
1484
- sessionKeyOf,
1485
- sessionKeyOfExecution,
1486
- normalizeState,
1487
- sectionOrder,
1488
- trimList,
1489
- assertPhase,
1490
- envelope,
1491
- nextInstruction,
1492
- invalidate,
1493
- stateCache,
1494
- }