@devflow-core/dsh-devflow 0.1.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.
Files changed (61) hide show
  1. package/NOTICE +13 -0
  2. package/README.md +82 -0
  3. package/assets/commands/devflow-adversarial.toml +11 -0
  4. package/assets/commands/devflow-audit.toml +32 -0
  5. package/assets/commands/devflow-debt.toml +42 -0
  6. package/assets/commands/devflow-find-fault.toml +11 -0
  7. package/assets/commands/devflow-learn.toml +21 -0
  8. package/assets/commands/devflow-plan.toml +58 -0
  9. package/assets/commands/devflow-prove.toml +20 -0
  10. package/assets/commands/devflow-pua.toml +40 -0
  11. package/assets/commands/devflow-review.toml +36 -0
  12. package/assets/commands/devflow-spec.toml +49 -0
  13. package/assets/commands/devflow.toml +35 -0
  14. package/assets/presets/devflow-2/NOTICE +4 -0
  15. package/assets/presets/devflow-2/README.md +71 -0
  16. package/assets/presets/devflow-2/agent.cordis.yml +337 -0
  17. package/assets/presets/devflow-2/custom-bash.mjs +213 -0
  18. package/assets/presets/devflow-2/preset.yml +3 -0
  19. package/assets/presets/devflow-2/tool-bootstrap.mjs +496 -0
  20. package/assets/scripts/devflow-audit.js +275 -0
  21. package/assets/scripts/devflow-debt.js +196 -0
  22. package/assets/scripts/devflow-doctor.js +90 -0
  23. package/assets/scripts/devflow-plan.js +638 -0
  24. package/assets/scripts/devflow-review.js +93 -0
  25. package/assets/scripts/devflow-spec.js +238 -0
  26. package/assets/skills/devflow-adversarial/SKILL.md +71 -0
  27. package/assets/skills/devflow-audit/SKILL.md +78 -0
  28. package/assets/skills/devflow-brainstorm/SKILL.md +176 -0
  29. package/assets/skills/devflow-brainstorm/references/interview-discipline.md +184 -0
  30. package/assets/skills/devflow-build/SKILL.md +238 -0
  31. package/assets/skills/devflow-build/references/build-methods.md +40 -0
  32. package/assets/skills/devflow-core/SKILL.md +93 -0
  33. package/assets/skills/devflow-core/references/core-methods.md +131 -0
  34. package/assets/skills/devflow-core/references/reference-projects.md +133 -0
  35. package/assets/skills/devflow-core/references/skill-guide.md +63 -0
  36. package/assets/skills/devflow-cut/SKILL.md +208 -0
  37. package/assets/skills/devflow-cut/references/cut-methods.md +65 -0
  38. package/assets/skills/devflow-cut/references/native-capability-checklist.md +112 -0
  39. package/assets/skills/devflow-docs-followup/SKILL.md +132 -0
  40. package/assets/skills/devflow-docs-followup/agents/openai.yaml +4 -0
  41. package/assets/skills/devflow-find-fault/SKILL.md +109 -0
  42. package/assets/skills/devflow-learn/SKILL.md +176 -0
  43. package/assets/skills/devflow-plan/SKILL.md +142 -0
  44. package/assets/skills/devflow-plan/references/plan-methods.md +74 -0
  45. package/assets/skills/devflow-project-knowledge/SKILL.md +354 -0
  46. package/assets/skills/devflow-prove/SKILL.md +216 -0
  47. package/assets/skills/devflow-prove/references/code-review-checklist.md +202 -0
  48. package/assets/skills/devflow-prove/references/flow-self-test.md +775 -0
  49. package/assets/skills/devflow-prove/references/proof-recovery-methods.md +26 -0
  50. package/assets/skills/devflow-pua/SKILL.md +197 -0
  51. package/assets/skills/devflow-pua/references/flavor-display.md +49 -0
  52. package/assets/skills/devflow-pua/references/methodology-library.md +193 -0
  53. package/assets/skills/devflow-pua/references/methodology-router.md +78 -0
  54. package/assets/skills/devflow-spec/SKILL.md +92 -0
  55. package/assets/skills/devflow-spec/references/spec-plan-methods.md +15 -0
  56. package/cordis.patch.yml +11 -0
  57. package/lib/dsh-home.js +33 -0
  58. package/lib/index.js +79 -0
  59. package/lib/mount-once.js +34 -0
  60. package/lib/sync.js +168 -0
  61. package/package.json +32 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * devflow-2 adaptation: identical two-phase bootstrap as dsh-liangshen, with
3
+ * the persona swap configured through `phase1Persona`. Phase 1 shows the
4
+ * one-line Minimal anchor; after promotion the assembled persona section (the
5
+ * preset's full DevFlow prompt from agent.cordis.yml) is restored untouched,
6
+ * and the workspace line is only appended when the persona does not already
7
+ * mention a working directory (the DevFlow template resolves {{cwd}} itself).
8
+ */
9
+
10
+ /**
11
+ * Keep the first model request on a minimal-shaped input surface, then expose
12
+ * the full preset catalog once the session is safely anchored.
13
+ *
14
+ * Phase 1 (no persisted `tool/call` yet):
15
+ * - tool catalog: one platform shell plus `commonTools`
16
+ * - prompt sections: only the persona section (all other sections,
17
+ * including plan-mode's `plan:policy`, return after promotion)
18
+ * - runtime contexts: emptied (no sandbox/approval snapshot)
19
+ * - pre-step messages: only explicit user messages pass
20
+ *
21
+ * Promotion opens the full tool catalog and restores runtime contexts and all
22
+ * prompt sections. With `anchorGate` the promotion after the first tool call
23
+ * also requires one minimal-like reasoning block (a first block containing
24
+ * `we` and no `let me`) or the `maxBootstrapSteps` fallback.
25
+ * `promoteAfterFirstResponse` promotes a tool-less first response once it has
26
+ * responded, and also releases an anchor-gated session when its first turn
27
+ * ends (`turn/end`). With `promotedPresentation: code` the promoted catalog
28
+ * is presented as Code Mode (PTC): the wire shows a single `run_code` tool
29
+ * backed by the generated SDK, switched at the step boundary so the current
30
+ * step's native calls are never interrupted. `deferredSources` and
31
+ * `deferredGraceSteps` delay selected injected message kinds (workspace
32
+ * instructions, skill catalog) for a few steps after promotion.
33
+ *
34
+ * COMPACTION (local addition, ported from the upstream compaction-epoch
35
+ * semantics): a compaction rewrites the whole model-visible surface, so the
36
+ * first post-compaction request is a "second first request". A
37
+ * `compaction/end` event releases Code Mode (the presentation disposer) and
38
+ * resets the promotion state to the CONTROLLED phase — bootstrap pair plus
39
+ * `compactionTools` (a core work set, default none) — until a NEW durable
40
+ * promotion signal exists past that boundary. The reset lives both in the
41
+ * live `session/event` path and inside the durable-log scan, so resume and
42
+ * reload reconstruct the same phase.
43
+ *
44
+ * ROBUSTNESS: composition drift (a missing bootstrap shell or common tool)
45
+ * degrades to the full catalog with a one-time warning instead of throwing,
46
+ * so a broken composition can never lock a session out of every request.
47
+ *
48
+ * OPT-IN PHASE-1 INSTRUCTION (issue #274): `phase1FirstCallInstruction` is
49
+ * an optional string appended to the phase-1 persona; unset (the default)
50
+ * keeps the phase-1 persona the exact one-line Minimal anchor. Test builds
51
+ * use it to ask the model to ground its first answer with one Minimal-native
52
+ * tool call before responding, so first-turn capability questions are
53
+ * answered from the promoted registry instead of the cropped two-tool view.
54
+ *
55
+ * Source: https://github.com/xiaobright/dsh-anchored-standard (MIT), extended
56
+ * with the phase-1 quarantine and the stabilization controls above.
57
+ */
58
+
59
+ /** Cordis plugin name used by loader diagnostics. */
60
+ export const name = 'anchored-tool-bootstrap'
61
+
62
+ /** Prompt assembly and the tool registry must exist before this filter runs. */
63
+ export const inject = ['systemPrompt', 'tools']
64
+
65
+ /**
66
+ * Prompt section names that carry the preset persona. The `dsh-persona` row
67
+ * registers the preset persona as `deployment:persona` (the PERSONA_SECTION
68
+ * name of `@deepseek-ai/dsh-system-prompt`), shadowing the deployment
69
+ * default for the preset scope; `persona` is the legacy name kept for older
70
+ * harnesses that registered the persona section without the prefix.
71
+ */
72
+ const PERSONA_SECTION_NAMES = new Set(['deployment:persona', 'persona'])
73
+
74
+ /**
75
+ * Workspace line a promoted persona gains. Phase 1 keeps the exact one-line
76
+ * persona (the Minimal anchor); after promotion the model must also know the
77
+ * session's selected workspace, which the Standard persona carries through
78
+ * the `{{cwd}}` prompt variable. The literal cwd is read from the session
79
+ * header at assembly time instead, so the line stays correct after a
80
+ * workspace switch and a session without a selected workspace keeps the bare
81
+ * one-liner rather than failing prompt interpolation.
82
+ */
83
+ const WORKSPACE_LINE_PREFIX = '\n\nYour working directory is '
84
+
85
+ /** Message-source kinds the model may see during phase 1. */
86
+ const DEFAULT_MESSAGE_SOURCES = ['user']
87
+
88
+ /** Message-source kinds delayed after promotion. */
89
+ const DEFAULT_DEFERRED_SOURCES = []
90
+
91
+ function stringList(value, field, fallback) {
92
+ if (value === undefined) return [...fallback]
93
+ if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || item.length === 0)) {
94
+ throw new TypeError(`${name}: ${field} must be a non-empty array of non-empty strings`)
95
+ }
96
+ return [...new Set(value)]
97
+ }
98
+
99
+ function stringListOrEmpty(value, field) {
100
+ if (value === undefined) return []
101
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length === 0)) {
102
+ throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
103
+ }
104
+ return [...new Set(value)]
105
+ }
106
+
107
+ function optionalString(value, field) {
108
+ if (value === undefined) return ''
109
+ if (typeof value !== 'string') {
110
+ throw new TypeError(`${name}: ${field} must be a string`)
111
+ }
112
+ return value
113
+ }
114
+
115
+ function integerAtLeast(value, field, minimum) {
116
+ if (!Number.isInteger(value) || value < minimum) {
117
+ throw new TypeError(`${name}: ${field} must be an integer >= ${minimum}`)
118
+ }
119
+ return value
120
+ }
121
+
122
+ function countWord(text, regex) {
123
+ return [...text.matchAll(regex)].length
124
+ }
125
+
126
+ /**
127
+ * Anchor classifier for promotion gating. A reasoning block counts as
128
+ * minimal-like when it contains `we` and no `let me`; a block with any
129
+ * `let me` is standard-like; everything else is ambiguous. This is a
130
+ * deliberate relaxation of the modeltest identity probe: the gate decides
131
+ * trajectory surface, not model identity, and `we` presence without
132
+ * first-person execution phrases is the stable surface marker.
133
+ */
134
+ export function classifyReasoning(text) {
135
+ const trimmed = String(text ?? '').trim()
136
+ const we = countWord(trimmed, /\bwe\b/gi)
137
+ const letMe = countWord(trimmed, /\blet me\b/gi)
138
+ const metrics = { we, letMe }
139
+ if (we > 0 && letMe === 0) return { label: 'minimal-like', score: 4, metrics }
140
+ if (letMe > 0) return { label: 'standard-like', score: -4, metrics }
141
+ return { label: 'ambiguous', score: 0, metrics }
142
+ }
143
+
144
+ /**
145
+ * Whether the FIRST reasoning block of an assistant message classifies as
146
+ * minimal-like. Later blocks do not override an earlier standard-like first
147
+ * block.
148
+ */
149
+ export function hasAnchoredReasoning(content) {
150
+ if (!Array.isArray(content)) return false
151
+ const first = content.find(block => block?.type === 'reasoning')
152
+ return first !== undefined && classifyReasoning(first.text).label === 'minimal-like'
153
+ }
154
+
155
+ /**
156
+ * Whether one pre-step message is an explicit user message. Only `kind:
157
+ * 'user'` passes; injected kinds and source-less seed messages never pass.
158
+ */
159
+ function isAllowedMessage(message, allowedSources) {
160
+ const kind = message.source?.kind
161
+ return kind === 'user' && allowedSources.has(kind)
162
+ }
163
+
164
+ /** Whether one pre-step message belongs to a deferred injection kind. */
165
+ function isDeferredMessage(message, deferredSources) {
166
+ const kind = message.source?.kind
167
+ return kind !== undefined && deferredSources.has(kind)
168
+ }
169
+
170
+ /**
171
+ * Phase-2 promotion state per session. Sessions append events only, so the
172
+ * scan resumes from the first event it has not inspected yet.
173
+ */
174
+ const promotionBySession = new WeakMap()
175
+
176
+ /** Live agents observed by the assemble/pre-step listeners, keyed by session. */
177
+ const agentBySession = new WeakMap()
178
+
179
+ function stateFor(session) {
180
+ let state = promotionBySession.get(session)
181
+ if (state === undefined) {
182
+ state = {
183
+ next: 0,
184
+ promoted: false,
185
+ toolCalled: false,
186
+ responded: false,
187
+ anchored: false,
188
+ turnEnded: false,
189
+ steps: 0,
190
+ deferredSteps: 0,
191
+ presentationApplied: false,
192
+ hasCompacted: false,
193
+ presentationDisposer: undefined,
194
+ }
195
+ promotionBySession.set(session, state)
196
+ }
197
+ return state
198
+ }
199
+
200
+ /**
201
+ * Reset one session back to the CONTROLLED phase after a compaction. A
202
+ * compaction rewrites the whole model-visible surface — the first
203
+ * post-compaction request is a "second first request" with the same
204
+ * first-token conditions the bootstrap exists to control — so the session
205
+ * re-anchors: promotion state is cleared (the durable `next` scan pointer is
206
+ * kept, so events recorded BEFORE the boundary never re-promote), and the
207
+ * Code Mode presentation is disposed so the next assembly sees the native
208
+ * catalog and the phase-1 filter can narrow it again.
209
+ */
210
+ function resetToControlled(state) {
211
+ if (typeof state.presentationDisposer === 'function') {
212
+ try {
213
+ state.presentationDisposer()
214
+ } catch {
215
+ // A failed presentation reset must never break the session; the
216
+ // next promotion re-declares Code Mode anyway.
217
+ }
218
+ state.presentationDisposer = undefined
219
+ }
220
+ state.promoted = false
221
+ state.toolCalled = false
222
+ state.responded = false
223
+ state.anchored = false
224
+ state.turnEnded = false
225
+ state.steps = 0
226
+ state.deferredSteps = 0
227
+ state.presentationApplied = false
228
+ state.hasCompacted = true
229
+ }
230
+
231
+ /**
232
+ * Switch one agent's wire presentation to Code Mode (PTC: a single `run_code`
233
+ * tool backed by the generated SDK) after promotion. `agent.ctx.tools` is the
234
+ * per-agent view of the host registry, so the switch affects this session only.
235
+ */
236
+ function applyPresentation(agent, state, policy) {
237
+ if (state.presentationApplied || policy.promotedPresentation !== 'code') return
238
+ state.presentationApplied = true
239
+ const tools = agent.ctx.tools
240
+ if (tools === undefined) return
241
+ // The disposer restores the deployment-default (native) presentation; it is
242
+ // kept on the state so a post-compaction reset can release Code Mode and
243
+ // let the phase-1 catalog filter see the native tool list again.
244
+ state.presentationDisposer = tools.presentAs('code')
245
+ }
246
+
247
+ /**
248
+ * a) first tool call, no anchor gate — promote immediately;
249
+ * b) first tool call, anchored or `maxBootstrapSteps` fallback — promote;
250
+ * c) first tool call, still gated, but the first turn ended and
251
+ * `promoteAfterFirstResponse` is set — release on the new user turn (the
252
+ * release happens during prompt assembly, so that turn already gets the
253
+ * full catalog);
254
+ * d) tool-less first response with `promoteAfterFirstResponse` — promote.
255
+ */
256
+ function decidePromotion(state, config) {
257
+ if (state.toolCalled && config.anchorGate !== true) return true
258
+ if (state.toolCalled && config.anchorGate === true && (state.anchored || state.steps >= config.maxBootstrapSteps)) return true
259
+ if (state.toolCalled && config.anchorGate === true && config.promoteAfterFirstResponse === true && state.turnEnded) return true
260
+ if (!state.toolCalled && state.responded && config.promoteAfterFirstResponse === true) return true
261
+ return false
262
+ }
263
+
264
+ /** Scan newly appended session events and update promotion state. */
265
+ function scanEvents(state, session) {
266
+ const events = session.events
267
+ for (; state.next < events.length; state.next += 1) {
268
+ const event = events[state.next]
269
+ if (event === undefined) continue
270
+ if (event.type === 'compaction/end') {
271
+ // A compaction rewrites the model-visible surface: the session falls
272
+ // back to the controlled phase until a NEW promotion signal exists
273
+ // past this boundary (the `next` pointer stays, so events before the
274
+ // boundary never re-promote). Handled inside the scan so cold starts
275
+ // reconstruct the same phase from the durable log.
276
+ resetToControlled(state)
277
+ } else if (event.type === 'tool/call') {
278
+ state.toolCalled = true
279
+ } else if (event.type === 'step/start') {
280
+ state.steps += 1
281
+ } else if (event.type === 'turn/end') {
282
+ state.turnEnded = true
283
+ } else if (event.type === 'assistant/message') {
284
+ state.responded = true
285
+ if (!state.anchored) state.anchored = hasAnchoredReasoning(event.data?.message?.content)
286
+ }
287
+ }
288
+ }
289
+
290
+ /** Update one agent's promotion state and apply its post-promotion presentation. */
291
+ function refresh(agent, policy) {
292
+ const session = agent?.session
293
+ if (session === undefined) return undefined
294
+ const state = stateFor(session)
295
+ agentBySession.set(session, agent)
296
+ if (!state.promoted) {
297
+ scanEvents(state, session)
298
+ if (decidePromotion(state, policy)) state.promoted = true
299
+ }
300
+ if (state.promoted) applyPresentation(agent, state, policy)
301
+ return state
302
+ }
303
+
304
+ /**
305
+ * Append the session's working directory to the persona section of a promoted
306
+ * assembly. Returns the assembly unchanged when there is no persona section,
307
+ * no selected workspace, or the exact line is already present.
308
+ */
309
+ function withWorkspaceLine(assembly, agent) {
310
+ const cwd = agent?.session?.header?.cwd
311
+ if (typeof cwd !== 'string' || cwd.length === 0) return assembly
312
+ if (!Array.isArray(assembly.sections)) return assembly
313
+ const line = `${WORKSPACE_LINE_PREFIX}${cwd}.`
314
+ const persona = assembly.sections.find(section =>
315
+ PERSONA_SECTION_NAMES.has(section?.name)
316
+ && typeof section?.text === 'string'
317
+ && !section.text.includes('Your working directory is '))
318
+ if (persona === undefined) return assembly
319
+ return {
320
+ ...assembly,
321
+ sections: assembly.sections.map(section => section === persona
322
+ ? { ...section, text: `${persona.text}${line}` }
323
+ : section),
324
+ }
325
+ }
326
+
327
+ /** Register the per-session bootstrap quarantine and promotion policy. */
328
+ export function apply(ctx, config) {
329
+ const commonTools = stringList(config.commonTools, 'commonTools')
330
+ const shellTools = stringList(config.shellTools, 'shellTools')
331
+ const messageSources = new Set(stringList(config.messageSources, 'messageSources', DEFAULT_MESSAGE_SOURCES))
332
+ const deferredSources = new Set(stringListOrEmpty(config.deferredSources, 'deferredSources'))
333
+ const presentation = config.promotedPresentation ?? 'native'
334
+ if (presentation !== 'native' && presentation !== 'code') {
335
+ throw new TypeError(`${name}: promotedPresentation must be "native" or "code"`)
336
+ }
337
+
338
+ let warned = false
339
+ const warnOnce = (message) => {
340
+ if (warned) return
341
+ warned = true
342
+ try {
343
+ ctx.logger.warn(message)
344
+ } catch {
345
+ // Logger unavailable — the guard exists only to avoid spamming.
346
+ }
347
+ }
348
+ const bootstrapMaxTokens = config.bootstrapMaxTokens === undefined
349
+ ? undefined
350
+ : integerAtLeast(config.bootstrapMaxTokens, 'bootstrapMaxTokens', 1)
351
+ // Core work set exposed during the post-compaction controlled phase, so a
352
+ // mid-task model keeps working with a small catalog instead of the full
353
+ // Standard set. Defaults to none: the session stays on the bootstrap pair
354
+ // until a new promotion signal (the composition may widen it via config).
355
+ const compactionTools = stringListOrEmpty(config.compactionTools, 'compactionTools')
356
+ // Opt-in extra line for the phase-1 persona (test builds, issue #274):
357
+ // asks the model to ground its first answer with a Minimal-native tool
358
+ // call before responding. Unset keeps the exact one-line persona.
359
+ const phase1FirstCallInstruction = optionalString(config.phase1FirstCallInstruction, 'phase1FirstCallInstruction')
360
+ // devflow-2: phase-1 persona override (one-line Minimal anchor); unset keeps the assembled persona text.
361
+ const phase1Persona = optionalString(config.phase1Persona, 'phase1Persona')
362
+ const policy = {
363
+ anchorGate: config.anchorGate === true,
364
+ promoteAfterFirstResponse: config.promoteAfterFirstResponse === true,
365
+ maxBootstrapSteps: integerAtLeast(config.maxBootstrapSteps ?? 4, 'maxBootstrapSteps', 1),
366
+ deferredGraceSteps: integerAtLeast(config.deferredGraceSteps ?? 0, 'deferredGraceSteps', 0),
367
+ promotedPresentation: presentation,
368
+ bootstrapMaxTokens,
369
+ compactionTools,
370
+ phase1FirstCallInstruction,
371
+ phase1Persona,
372
+ }
373
+
374
+ // Promotion is applied at step/turn boundaries, never while a step is still
375
+ // executing tools: switching the presentation mid-step would collapse the
376
+ // native calls that step already planned. By `step/end` the tool-call and
377
+ // reasoning events are durable, so the NEXT prompt assembly already sees
378
+ // Code Mode with its generated SDK section. A `compaction/end` event
379
+ // releases Code Mode and resets the promotion state (see
380
+ // resetToControlled); the reset also runs inside scanEvents, so a cold
381
+ // start reconstructs the same controlled phase from the durable log.
382
+ ctx.on('session/event', (session, event) => {
383
+ if (event.type === 'compaction/end') {
384
+ resetToControlled(stateFor(session))
385
+ return
386
+ }
387
+ if (event.type !== 'step/end' && event.type !== 'turn/end') return
388
+ const state = stateFor(session)
389
+ if (!state.promoted) {
390
+ scanEvents(state, session)
391
+ if (decidePromotion(state, policy)) state.promoted = true
392
+ }
393
+ if (state.promoted) {
394
+ const agent = agentBySession.get(session)
395
+ if (agent !== undefined) applyPresentation(agent, state, policy)
396
+ }
397
+ })
398
+
399
+ // `prepend: true` puts both filters at the outermost position of their
400
+ // waterfall, so `await next()` always observes the complete downstream
401
+ // result (including messages appended by listener order, not row order)
402
+ // before the quarantine strips it.
403
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
404
+ // Downstream errors propagate untouched; only this filter's own logic is
405
+ // guarded (a filter bug must never brick every request of a session).
406
+ const assembled = await next()
407
+ const agent = context.agent
408
+ if (agent === undefined) return assembled
409
+ const state = refresh(agent, policy)
410
+ if (state.promoted) return withWorkspaceLine(assembled, agent)
411
+
412
+ const available = new Set(assembled.tools.map(tool => tool.name))
413
+ const selectedShells = shellTools.filter(toolName => available.has(toolName))
414
+ const missingCommon = commonTools.filter(toolName => !available.has(toolName))
415
+ if (selectedShells.length !== 1 || missingCommon.length > 0) {
416
+ // Composition drift must not lock a session out: degrade to the full
417
+ // catalog with a one-time warning instead of throwing (the bootstrap
418
+ // phase surfaces will simply not apply).
419
+ warnOnce(
420
+ `${name}: expected exactly one bootstrap shell and every common tool; `
421
+ + `shells=${JSON.stringify(selectedShells)}, missing=${JSON.stringify(missingCommon)} — `
422
+ + 'bootstrap disabled, full catalog exposed',
423
+ )
424
+ return assembled
425
+ }
426
+
427
+ const bootstrap = new Set([...selectedShells, ...commonTools])
428
+ // After a compaction the controlled phase widens with the core work set
429
+ // so mid-task work can continue before re-promotion.
430
+ if (state.hasCompacted) for (const toolName of compactionTools) bootstrap.add(toolName)
431
+ const sections = Array.isArray(assembled.sections)
432
+ ? assembled.sections.filter(section => PERSONA_SECTION_NAMES.has(section?.name))
433
+ : undefined
434
+ // Opt-in phase-1 instruction: appended once to the persona section so
435
+ // test builds can shift the first answer behind a Minimal-native tool
436
+ // call (issue #274). Unset leaves the exact one-line persona.
437
+ const personaText = (section) => {
438
+ if (typeof section?.text !== 'string') return section
439
+ let text = phase1Persona !== '' ? phase1Persona : section.text
440
+ if (phase1FirstCallInstruction !== '' && !text.includes(phase1FirstCallInstruction)) {
441
+ text = `${text}${phase1FirstCallInstruction}`
442
+ }
443
+ return { ...section, text }
444
+ }
445
+ const phase1Sections = sections === undefined ? sections : sections.map(personaText)
446
+ return {
447
+ ...assembled,
448
+ tools: assembled.tools.filter(tool => bootstrap.has(tool.name)),
449
+ contexts: [],
450
+ ...(phase1Sections !== undefined ? { sections: phase1Sections } : {}),
451
+ }
452
+ }, { prepend: true })
453
+
454
+ ctx.on('agent/pre-step', async (payload, next) => {
455
+ const decision = await next()
456
+ const agent = payload.agent
457
+ if (agent === undefined || decision.kind !== 'enter') return decision
458
+ const state = refresh(agent, policy)
459
+ if (state === undefined) return decision
460
+
461
+ if (!state.promoted) {
462
+ return {
463
+ ...decision,
464
+ messages: decision.messages.filter(message => isAllowedMessage(message, messageSources)),
465
+ }
466
+ }
467
+ if (state.deferredSteps < policy.deferredGraceSteps) {
468
+ state.deferredSteps += 1
469
+ return {
470
+ ...decision,
471
+ messages: decision.messages.filter(message => !isDeferredMessage(message, deferredSources)),
472
+ }
473
+ }
474
+ return decision
475
+ }, { prepend: true })
476
+
477
+ // Phase 1 caps the next request output budget to bootstrapMaxTokens, the
478
+ // community-observed We-need trigger window (dsh-anchored-standard issue 6),
479
+ // and strips the cap again after promotion. The strip is mandatory:
480
+ // requestProposal(persistedHeader) carries a plain maxTokens from the
481
+ // previous header into the next request unless the adapter marked it a
482
+ // default, so an un-stripped cap would be soldered into every request.
483
+ ctx.on('agent/request', async (payload, next) => {
484
+ const resolved = await next()
485
+ const agent = payload?.agent
486
+ if (agent === undefined || policy.bootstrapMaxTokens === undefined) return resolved
487
+ const state = refresh(agent, policy)
488
+ if (state.promoted) {
489
+ if (resolved.maxTokens !== policy.bootstrapMaxTokens) return resolved
490
+ const rest = { ...resolved }
491
+ delete rest.maxTokens
492
+ return rest
493
+ }
494
+ return { ...resolved, maxTokens: policy.bootstrapMaxTokens }
495
+ }, { prepend: true })
496
+ }