deepseek-coordination 0.1.1 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +271 -14
- package/README.zh-CN.md +160 -11
- package/client.js +338 -0
- package/cordis.patch.yml +40 -0
- package/package.json +22 -2
- package/src/host/bin/dsh-os.ps1 +10 -1
- package/src/host/bin/dsh-os7.ps1 +16 -1
- package/src/plugin/index.js +4 -213
- package/src/plugin/invoke.js +301 -0
- package/src/plugin/settings.js +340 -0
- package/src/plugin/takeover.js +432 -0
- package/src/plugin/workflow.js +1464 -0
- package/tools/measure-cache.mjs +223 -0
|
@@ -0,0 +1,1464 @@
|
|
|
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. It is in the tool catalog, which sits inside the cached prefix and is sent once — so clarity
|
|
1203
|
+
* here is cheaper than brevity, but it still has to be short enough not to bloat the prefix.
|
|
1204
|
+
*/
|
|
1205
|
+
const TOOL_DESCRIPTION = [
|
|
1206
|
+
'Intent-first workflow: agree on what is wanted and how it will be done, before doing it. Use it when a',
|
|
1207
|
+
'request is more than one obvious step, or when you would have to guess the user\'s intent, their',
|
|
1208
|
+
'preferred approach, or where the work belongs. Four calls:',
|
|
1209
|
+
'"start" (the prompt verbatim, plus 1-3 questions about intent OR the assumptions you are making) ->',
|
|
1210
|
+
'"plan" (approach and process, at least two alternatives with pros and cons, your recommendation) ->',
|
|
1211
|
+
'"choose" (ASKS THE USER; you cannot answer it for them) ->',
|
|
1212
|
+
'"run" (execute the chosen option; add "outcome" to close the workflow in the same call).',
|
|
1213
|
+
'Then "note" for a decision, "checkpoint" at a boundary. Long documents go to files and only a line',
|
|
1214
|
+
'returns, so the conversation stays small; results list the files. A step out of order is refused, and the',
|
|
1215
|
+
'refusal names the phase to record first. Every result carries "next", the one call to make. Also "status", "reset".',
|
|
1216
|
+
].join(' ')
|
|
1217
|
+
|
|
1218
|
+
const QUESTION_SCHEMA = {
|
|
1219
|
+
type: 'object',
|
|
1220
|
+
additionalProperties: false,
|
|
1221
|
+
properties: {
|
|
1222
|
+
id: { type: 'string', description: 'Stable id, echoed in the answer. Defaults to q1, q2, ...' },
|
|
1223
|
+
header: { type: 'string', description: 'Short heading, e.g. "Intent" or "Constraint".' },
|
|
1224
|
+
question: { type: 'string', description: 'The question itself, in the user\'s own terms.' },
|
|
1225
|
+
options: {
|
|
1226
|
+
type: 'array',
|
|
1227
|
+
description: 'Optional choices to offer. If you recommend one, put it first and append " (Recommended)".',
|
|
1228
|
+
items: {
|
|
1229
|
+
type: 'object',
|
|
1230
|
+
additionalProperties: false,
|
|
1231
|
+
properties: {
|
|
1232
|
+
label: { type: 'string', description: 'Short user-facing label.' },
|
|
1233
|
+
description: { type: 'string', description: 'One sentence on the trade-off.' },
|
|
1234
|
+
},
|
|
1235
|
+
required: ['label'],
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
},
|
|
1239
|
+
required: ['question'],
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const OPTION_SCHEMA = {
|
|
1243
|
+
type: 'object',
|
|
1244
|
+
additionalProperties: false,
|
|
1245
|
+
properties: {
|
|
1246
|
+
id: { type: 'string', description: 'Short id, e.g. o1. Defaults to the position.' },
|
|
1247
|
+
title: { type: 'string', description: 'The approach, named in a way the user will recognise.' },
|
|
1248
|
+
pros: { type: 'array', items: { type: 'string' }, description: 'What this approach buys.' },
|
|
1249
|
+
cons: { type: 'array', items: { type: 'string' }, description: 'What it costs, including what it rules out.' },
|
|
1250
|
+
criteria: { type: 'string', description: 'The criterion this option does best or worst on.' },
|
|
1251
|
+
recommended: { type: 'boolean', description: 'Set on exactly one option to put it first for the user.' },
|
|
1252
|
+
},
|
|
1253
|
+
required: ['title'],
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** The action list, in the order the description explains them. */
|
|
1257
|
+
export const ACTIONS = ['start', 'plan', 'choose', 'run', 'note', 'checkpoint', 'status', 'reset']
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* The cost model, asserted by the tests.
|
|
1261
|
+
*
|
|
1262
|
+
* It exists as data rather than prose because the whole point of the fused phases is a number. `toolCalls`
|
|
1263
|
+
* and `humanInteractions` are the round-trips; `promptChangesPerWorkflow` is the cache property, and it is
|
|
1264
|
+
* zero: the system prompt is a constant, so no phase of any workflow can invalidate the provider's cached
|
|
1265
|
+
* prefix. A change that reintroduces a phase, or that lets state reach the prompt, has to change this
|
|
1266
|
+
* object and therefore fail a test.
|
|
1267
|
+
*/
|
|
1268
|
+
export const COST_MODEL = {
|
|
1269
|
+
shortestPath: ['start', 'plan', 'choose', 'run'],
|
|
1270
|
+
toolCalls: 4,
|
|
1271
|
+
humanInteractions: 1,
|
|
1272
|
+
// The number of times the plugin's system-prompt contribution changes over one whole workflow. Not
|
|
1273
|
+
// "small" — zero, because anything else costs the entire prefix.
|
|
1274
|
+
promptChangesPerWorkflow: 0,
|
|
1275
|
+
cacheEffect: 'prefix-stable',
|
|
1276
|
+
previous: { toolCalls: 8, humanInteractions: 2, promptChangesPerWorkflow: 3 },
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* The model-facing tool name.
|
|
1281
|
+
*
|
|
1282
|
+
* NOT `workflow`. A host composition already exposes a built-in tool by that name — the JavaScript
|
|
1283
|
+
* multi-agent orchestrator from `@deepseek-ai/dsh-tool-workflow` — and two tools sharing one name within a
|
|
1284
|
+
* scope collide: whoever registers second aborts, or both resolve and the model is offered two unrelated
|
|
1285
|
+
* `workflow` tools. `_plan` also says what this one is: the planning half of a workflow, leaving
|
|
1286
|
+
* `workflow` for the orchestrator.
|
|
1287
|
+
*/
|
|
1288
|
+
export const TOOL_NAME = 'workflow_plan'
|
|
1289
|
+
|
|
1290
|
+
/** Exported so a test can assert the tool contract without a live registry. */
|
|
1291
|
+
export function workflowToolDefinition(handlers) {
|
|
1292
|
+
return {
|
|
1293
|
+
name: TOOL_NAME,
|
|
1294
|
+
description: TOOL_DESCRIPTION,
|
|
1295
|
+
parameters: {
|
|
1296
|
+
type: 'object',
|
|
1297
|
+
properties: {
|
|
1298
|
+
action: { type: 'string', enum: ACTIONS, description: 'The phase to record. A later phase is refused until the earlier one is recorded.' },
|
|
1299
|
+
intent: { type: 'string', description: 'start: the user\'s request, verbatim. Required by start.' },
|
|
1300
|
+
questions: { type: 'array', items: QUESTION_SCHEMA, description: 'start: 1-3 questions for the user about intent. Give this or "assumptions".' },
|
|
1301
|
+
assumptions: { type: 'array', items: { type: 'string' }, description: 'start: what you are taking for granted, when the request is clear enough to ask nothing.' },
|
|
1302
|
+
cwd: { type: 'string', description: 'start: absolute path the work happens in. Defaults to the session working directory.' },
|
|
1303
|
+
reset: { type: 'boolean', description: 'start: discard an active workflow for this session and begin a new one.' },
|
|
1304
|
+
content: { type: 'string', description: 'plan: the approach and process, as markdown. Required by plan.' },
|
|
1305
|
+
options: { type: 'array', items: OPTION_SCHEMA, description: 'plan: at least two alternatives with pros and cons. Required by plan.' },
|
|
1306
|
+
criteria: { type: 'string', description: 'plan: the criteria the alternatives were judged against.' },
|
|
1307
|
+
recommendation: { type: 'string', description: 'plan: the id or title of the option you recommend.' },
|
|
1308
|
+
summary: { type: 'string', description: 'plan: the one line this phase contributes to later context.' },
|
|
1309
|
+
replace: { type: 'boolean', description: 'plan: record a revision after the user rejected the plan, keeping the original.' },
|
|
1310
|
+
question: { type: 'string', description: 'choose: the question shown to the user. Defaults to asking which option to run.' },
|
|
1311
|
+
goal: { type: 'string', description: 'run: what this execution must achieve.' },
|
|
1312
|
+
steps: { type: 'array', items: { type: 'string' }, description: 'run: the ordered steps.' },
|
|
1313
|
+
outcome: { type: 'string', description: 'run: what was delivered. Passing it closes the workflow in the same call.' },
|
|
1314
|
+
files: { type: 'array', items: { type: 'string' }, description: 'run: files in scope.' },
|
|
1315
|
+
evidence: { type: 'string', description: 'run: how the work will be, or was, verified.' },
|
|
1316
|
+
constraints: { type: 'array', items: { type: 'string' }, description: 'run: what must not change.' },
|
|
1317
|
+
status: { type: 'string', enum: ['done', 'pending'], description: 'run: with "note", marks that step done or leaves it open.' },
|
|
1318
|
+
note: { type: 'string', description: 'run: the step being marked. In "note", the remark to record.' },
|
|
1319
|
+
text: { type: 'string', description: 'note: the decision or remark to record.' },
|
|
1320
|
+
kind: { type: 'string', enum: ['note', 'decision'], description: 'note: a decision is carried in the state; a note stays in the journal.' },
|
|
1321
|
+
reason: { type: 'string', description: 'checkpoint: why the compression is happening now.' },
|
|
1322
|
+
},
|
|
1323
|
+
required: ['action'],
|
|
1324
|
+
},
|
|
1325
|
+
output: {
|
|
1326
|
+
schema: { type: 'string' },
|
|
1327
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
1328
|
+
},
|
|
1329
|
+
async execute(args, exec) {
|
|
1330
|
+
const key = sessionKeyOfExecution(exec)
|
|
1331
|
+
const action = String(args.action ?? '').trim()
|
|
1332
|
+
let value
|
|
1333
|
+
switch (action) {
|
|
1334
|
+
case 'start':
|
|
1335
|
+
if (typeof args.intent !== 'string' || args.intent.trim() === '') {
|
|
1336
|
+
throw new Error('start needs "intent": the user\'s request in their own words, not a paraphrase of it')
|
|
1337
|
+
}
|
|
1338
|
+
value = await handlers.start({
|
|
1339
|
+
intent: args.intent,
|
|
1340
|
+
questions: args.questions,
|
|
1341
|
+
assumptions: args.assumptions,
|
|
1342
|
+
cwd: args.cwd ?? exec?.agent?.session?.header?.cwd,
|
|
1343
|
+
key,
|
|
1344
|
+
reset: args.reset === true,
|
|
1345
|
+
}, exec)
|
|
1346
|
+
break
|
|
1347
|
+
case 'plan':
|
|
1348
|
+
value = handlers.plan({
|
|
1349
|
+
content: args.content,
|
|
1350
|
+
options: args.options,
|
|
1351
|
+
criteria: args.criteria,
|
|
1352
|
+
summary: args.summary,
|
|
1353
|
+
recommendation: args.recommendation,
|
|
1354
|
+
replace: args.replace === true,
|
|
1355
|
+
key,
|
|
1356
|
+
})
|
|
1357
|
+
break
|
|
1358
|
+
case 'choose':
|
|
1359
|
+
value = await handlers.choose({ question: args.question, key }, exec)
|
|
1360
|
+
break
|
|
1361
|
+
case 'run':
|
|
1362
|
+
value = handlers.run({
|
|
1363
|
+
goal: args.goal,
|
|
1364
|
+
steps: args.steps,
|
|
1365
|
+
outcome: args.outcome,
|
|
1366
|
+
files: args.files,
|
|
1367
|
+
evidence: args.evidence,
|
|
1368
|
+
constraints: args.constraints,
|
|
1369
|
+
status: args.status,
|
|
1370
|
+
note: args.note ?? args.text,
|
|
1371
|
+
key,
|
|
1372
|
+
})
|
|
1373
|
+
break
|
|
1374
|
+
case 'note':
|
|
1375
|
+
value = handlers.note({ text: args.text ?? args.note, kind: args.kind, key })
|
|
1376
|
+
break
|
|
1377
|
+
case 'checkpoint':
|
|
1378
|
+
value = handlers.checkpoint({ key, reason: args.reason })
|
|
1379
|
+
break
|
|
1380
|
+
case 'status':
|
|
1381
|
+
value = handlers.status({ key })
|
|
1382
|
+
break
|
|
1383
|
+
case 'reset':
|
|
1384
|
+
value = handlers.reset({ key })
|
|
1385
|
+
break
|
|
1386
|
+
default:
|
|
1387
|
+
throw new Error(`unknown workflow action '${action}': expected one of ${ACTIONS.join(', ')}`)
|
|
1388
|
+
}
|
|
1389
|
+
return JSON.stringify(value)
|
|
1390
|
+
},
|
|
1391
|
+
// A workflow step rewrites one session's own documents; two of them touching the same state would
|
|
1392
|
+
// interleave their appends.
|
|
1393
|
+
isConcurrencySafe: () => false,
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
export function apply(ctx, config = {}) {
|
|
1398
|
+
const root = typeof config.workflowRoot === 'string' && config.workflowRoot.trim() !== ''
|
|
1399
|
+
? config.workflowRoot
|
|
1400
|
+
: workflowRoot()
|
|
1401
|
+
const autoCompact = config.autoCompact !== false
|
|
1402
|
+
const maxCheckpoints = Number.isInteger(config.maxCheckpoints) && config.maxCheckpoints > 0 ? config.maxCheckpoints : 20
|
|
1403
|
+
|
|
1404
|
+
// The engine's own automatic compaction is named in the checkpoint result rather than invoked: this
|
|
1405
|
+
// plugin holds no session handle, and a call into a service whose context contract is not published
|
|
1406
|
+
// would be a guess dressed up as a feature. What it does instead is deterministic — the detail leaves
|
|
1407
|
+
// the conversation for a file, and the boundary is recorded where a compaction can see it.
|
|
1408
|
+
const compact = (state) => {
|
|
1409
|
+
const boundaries = state.checkpoints.length
|
|
1410
|
+
if (!autoCompact) {
|
|
1411
|
+
return `${boundaries} checkpoint(s) recorded; automatic compaction is disabled by configuration`
|
|
1412
|
+
}
|
|
1413
|
+
return `${boundaries} checkpoint(s) recorded (limit ${maxCheckpoints}); the full record is on disk, so the conversation can drop it and keep the summary`
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const handlers = makeHandlers({
|
|
1417
|
+
root,
|
|
1418
|
+
compact,
|
|
1419
|
+
logger: () => { /* reserved for a future activity feed; kept so the seam exists in one place */ },
|
|
1420
|
+
ask: async (questions, exec) => {
|
|
1421
|
+
const userQuestions = ctx.userQuestions
|
|
1422
|
+
if (userQuestions === undefined) {
|
|
1423
|
+
throw new Error('the workflow_plan tool needs the userQuestions service to ask the user, and this deployment has none')
|
|
1424
|
+
}
|
|
1425
|
+
const request = {
|
|
1426
|
+
questions,
|
|
1427
|
+
...exec?.agent === undefined ? {} : { agent: exec.agent },
|
|
1428
|
+
...exec?.signal === undefined ? {} : { signal: exec.signal },
|
|
1429
|
+
}
|
|
1430
|
+
// `wait.callId` is what keeps the question answerable in the transcript after the call returns,
|
|
1431
|
+
// and it exists only for a real tool call.
|
|
1432
|
+
return userQuestions.ask(exec?.callId === undefined ? request : { ...request, wait: { callId: exec.callId } })
|
|
1433
|
+
},
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
if (typeof ctx.tools?.register === 'function') ctx.tools.register(workflowToolDefinition(handlers))
|
|
1437
|
+
|
|
1438
|
+
// The prompt half is registered only when the registry is present, so a deployment without it loses
|
|
1439
|
+
// the section rather than the feature. The text is a constant: no state is read, nothing is looked up,
|
|
1440
|
+
// and the model's cached prefix survives every phase of every workflow. See SECTION_TEXT.
|
|
1441
|
+
if (typeof ctx.systemPrompt?.section === 'function') {
|
|
1442
|
+
ctx.systemPrompt.section({
|
|
1443
|
+
name: 'dsh-os:workflow',
|
|
1444
|
+
order: sectionOrder(ctx.systemPrompt),
|
|
1445
|
+
text: SECTION_TEXT,
|
|
1446
|
+
})
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return handlers
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** Exported for tests, so the state machine can be exercised without a live plugin context. */
|
|
1453
|
+
export const __internals = {
|
|
1454
|
+
sessionKeyOf,
|
|
1455
|
+
sessionKeyOfExecution,
|
|
1456
|
+
normalizeState,
|
|
1457
|
+
sectionOrder,
|
|
1458
|
+
trimList,
|
|
1459
|
+
assertPhase,
|
|
1460
|
+
envelope,
|
|
1461
|
+
nextInstruction,
|
|
1462
|
+
invalidate,
|
|
1463
|
+
stateCache,
|
|
1464
|
+
}
|