Plan mode's stage 1 (RFC 2026-07-07-plan-mode): a new packages/mode/ group
with one product package owning the mode/set SessionEventMap vocabulary
(log-only, non-surface, whole-value replace), the pure foldMode, and the
ctx.modes service (list/get/set). User flips are pending intents flushed
at turn/start / step/end — turn enclosure makes an idle append illegal —
with one coalesced context/message notice when the flushed mode differs
from what the last logged request header told the model; a folded mode
the config no longer defines reads as default plus one boundary notice.
Enforcement is two covering layers: a system-prompt/assemble wrapper
filters the RETURNED assembly's tools to the mode's allowlist (and shows
exit_plan_mode IFF the folded mode is plan) beside the mode:policy
section at order 50, and a tools/pre-execute gate denies deny-by-default
against the same allowlist, judging by the logged mode only. The default
mode is the absence of policy — assemblies stay byte-identical to a
no-dsh-mode deployment.
AgentOptions.mode (declaration-merged) seeds a child's initial mode
through the same flush on agent/created; the stdio app gains /mode
(print/switch, never sent to the model) over an opportunistic
ctx.get('modes'). Config is an explicit resolve step: the built-in plan
definition (read-only allowlist; bash/subagent excluded until the
sandbox family lands) merges unless overridden, 'default' as a key
throws at load, unknown names throw at set() time.
196 lines
6.9 KiB
TypeScript
196 lines
6.9 KiB
TypeScript
/**
|
|
* Generate (and verify) the module dependency graph in docs/module-graph.md.
|
|
*
|
|
* The architectural shape of the harness lives implicitly in each package's
|
|
* `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror
|
|
* these as `workspace:^` plus test-only extras, which would add noise). This
|
|
* script reads every `packages/* /* /package.json`, keeps only the
|
|
* `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a
|
|
* GitHub-viewable Mermaid graph grouped by `packages/<group>/` plus a
|
|
* dependency table.
|
|
*
|
|
* The file is fully generated — never hand-edit it. Output is deterministic
|
|
* (packages and edges sorted) so a regenerate-and-diff freshness check is
|
|
* stable.
|
|
*
|
|
* `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md
|
|
* `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file
|
|
* is stale (CI / pre-push gate)
|
|
*/
|
|
|
|
import { dirname, resolve } from 'node:path'
|
|
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
|
const root = resolve(import.meta.dirname, '..')
|
|
const OUT = 'docs/module-graph.md'
|
|
const SCOPE = '@deepseek-ai/dsh-'
|
|
|
|
interface Pkg {
|
|
/** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */
|
|
short: string
|
|
/** Package group from `packages/<group>/<pkg>`. */
|
|
group: string
|
|
/** Repo-relative package directory. */
|
|
rel: string
|
|
/** Short names of this package's in-repo peer dependencies, sorted. */
|
|
deps: string[]
|
|
}
|
|
|
|
const GROUP_ORDER = [
|
|
'util',
|
|
'llm',
|
|
'core',
|
|
'bash',
|
|
'fs',
|
|
'compact',
|
|
'subagent',
|
|
'web',
|
|
'timeout',
|
|
'todo',
|
|
'mode',
|
|
'cordis',
|
|
'hooks',
|
|
'session-persistence',
|
|
'support',
|
|
'ui',
|
|
]
|
|
|
|
/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
|
|
function collect(): Pkg[] {
|
|
const pkgs: Pkg[] = []
|
|
for (const rel of globSync('packages/*/*/package.json', { cwd: root })) {
|
|
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
|
|
name: string
|
|
peerDependencies?: Record<string, string>
|
|
}
|
|
if (!json.name.startsWith(SCOPE)) continue
|
|
const deps = Object.keys(json.peerDependencies ?? {})
|
|
.filter(d => d.startsWith(SCOPE))
|
|
.map(d => d.slice(SCOPE.length))
|
|
.sort()
|
|
const [, group, leaf] = rel.split('/')
|
|
if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`)
|
|
pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), deps })
|
|
}
|
|
return topoSort(pkgs)
|
|
}
|
|
|
|
/**
|
|
* Order packages low-level → high-level: a package appears only after every
|
|
* package it depends on. Kahn-style layering with an alphabetical tiebreak
|
|
* within each layer, so the output stays deterministic (the freshness check
|
|
* compares whole-file). The graph is a DAG, so this always terminates; a cycle
|
|
* would leave nodes unplaced and throw.
|
|
*/
|
|
function topoSort(pkgs: Pkg[]): Pkg[] {
|
|
const remaining = new Map(pkgs.map(p => [p.short, p]))
|
|
const placed = new Set<string>()
|
|
const out: Pkg[] = []
|
|
while (remaining.size > 0) {
|
|
const ready = [...remaining.values()]
|
|
.filter(p => p.deps.every(d => placed.has(d)))
|
|
.sort(comparePackages)
|
|
if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
|
for (const p of ready) {
|
|
out.push(p)
|
|
placed.add(p.short)
|
|
remaining.delete(p.short)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function comparePackages(a: Pkg, b: Pkg): number {
|
|
const groupA = GROUP_ORDER.indexOf(a.group)
|
|
const groupB = GROUP_ORDER.indexOf(b.group)
|
|
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
|
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
|
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
|
}
|
|
|
|
function nodeId(prefix: string, value: string): string {
|
|
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
|
}
|
|
|
|
function escLabel(value: string): string {
|
|
return value.replace(/"/g, '\\"')
|
|
}
|
|
|
|
function packageLink(pkg: Pkg): string {
|
|
return `[\`${pkg.short}\`](../${pkg.rel})`
|
|
}
|
|
|
|
/** Render the full docs/module-graph.md content (pure, deterministic). */
|
|
function render(pkgs: Pkg[]): string {
|
|
const edges: string[] = []
|
|
for (const p of pkgs) {
|
|
for (const d of p.deps) edges.push(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`)
|
|
}
|
|
const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
|
|
const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => {
|
|
const ia = GROUP_ORDER.indexOf(a)
|
|
const ib = GROUP_ORDER.indexOf(b)
|
|
const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia
|
|
const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib
|
|
return na - nb || a.localeCompare(b)
|
|
})
|
|
const groupBlocks: string[] = []
|
|
for (const group of groups) {
|
|
groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`)
|
|
for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) {
|
|
groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`)
|
|
}
|
|
groupBlocks.push(' end')
|
|
}
|
|
const rows = pkgs.map((p) => {
|
|
const deps = p.deps.length ? p.deps.map((d) => {
|
|
const dep = byShort.get(d)
|
|
return dep ? packageLink(dep) : `\`${d}\``
|
|
}).join(', ') : '—'
|
|
return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |`
|
|
})
|
|
return [
|
|
'<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.',
|
|
' Run `pnpm run gen-module-graph` to regenerate. -->',
|
|
'',
|
|
'# Module dependency graph',
|
|
'',
|
|
'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.',
|
|
'',
|
|
'```mermaid',
|
|
'flowchart TD',
|
|
...groupBlocks,
|
|
...edges,
|
|
'```',
|
|
'',
|
|
'| Package | Group | Depends on |',
|
|
'| --- | --- | --- |',
|
|
...rows,
|
|
'',
|
|
].join('\n')
|
|
}
|
|
|
|
const content = render(collect())
|
|
|
|
if (process.argv.includes('--check')) {
|
|
let committed: string | null = null
|
|
try {
|
|
committed = readFileSync(resolve(root, OUT), 'utf8')
|
|
} catch {
|
|
// Only an ENOENT (file not yet generated) is expected here; readFileSync of
|
|
// a present-but-unreadable file is not a state this repo produces. Either
|
|
// way the remedy is the same — regenerate — so we treat a read failure as
|
|
// "stale" and fall through to the failure branch below.
|
|
committed = null
|
|
}
|
|
if (committed === content) {
|
|
console.log(`gen-module-graph: ${OUT} is up to date.`)
|
|
process.exit(0)
|
|
}
|
|
console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
writeFileSync(resolve(root, OUT), content)
|
|
console.log(`gen-module-graph: wrote ${OUT}.`)
|