Merge remote-tracking branch 'origin/master' into codex/invariant-service-seam

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md
#	.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
#	.agents/notes/implemented/feature/2026-07-07-session-prefix.md
#	.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml
#	docs/architecture.md
#	docs/config-catalog.md
#	docs/core-data-structures/session.md
#	docs/rfc/INDEX.md
#	packages/examples/agent-spine-demo/README.md
#	packages/examples/agent-spine-demo/src/index.ts
#	packages/examples/agent-spine-demo/tests/agent-core.spec.ts
#	packages/support/invariants/README.md
#	packages/support/invariants/src/index.ts
#	packages/support/invariants/tests/invariants.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-20 00:25:31 +08:00
676 changed files with 6946 additions and 2405 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
# `@deepseek-ai/dsh-helper`
Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture RFC](../../../docs/rfc/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale.
Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture Agent Note](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale.
The package owns the builtin typed-spec catalog, provider/app behavior entities, structured project file objects, helper-owned project templates, the shared typed `TextTemplate` renderer, package-manager strategies, local-plugin blueprints, typed questions, and the clack prompt adapter. It never boots a Cordis application.
@@ -18,6 +18,10 @@ The package root explicitly exports only the objects consumed by `create-sdk` an
None, as the project domain edits files and never mounts a live agent or model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Commit is not transactional across files** — external edits are detected before each write, but a later failure does not roll back files already written.
@@ -26,6 +26,7 @@ export class FeatureConfigurator {
* @param current - currently installed selection, when configuring.
* @param prefilledOptions - options already chosen by a tree picker.
* @param prefilledSecrets - non-interactive secret values supplied by creation.
* @param prefilledValues - non-interactive value inputs supplied by a headless spec.
* @returns normalized selection with captured values and secrets.
*/
async configure(
@@ -34,6 +35,7 @@ export class FeatureConfigurator {
current?: FeatureSelection,
prefilledOptions?: readonly string[],
prefilledSecrets: Readonly<Record<string, string>> = {},
prefilledValues: Readonly<Record<string, unknown>> = {},
): Promise<FeatureSelection> {
let options: readonly string[]
switch (feature.mode) {
@@ -69,6 +71,11 @@ export class FeatureConfigurator {
id: feature.id,
options,
}
const coercedPrefilled: Record<string, string> = {}
for (const [key, value] of Object.entries(prefilledValues)) {
if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`)
coercedPrefilled[key] = value
}
const values: Record<string, string> = {}
for (const input of feature.valueInputs(selected, profile)) {
const existing = current?.values?.[input.id]
@@ -81,7 +88,7 @@ export class FeatureConfigurator {
...existing === undefined ? {} : { initialValue: existing },
validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
})
values[input.id] = requireAnswer(await question.resolve(this.port))
values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
}
const base: FeatureSelection = Object.keys(values).length === 0
? selected
+1
View File
@@ -43,3 +43,4 @@ export {
} from './questions/question.ts'
export type { Question } from './questions/question.ts'
export { ClackPromptPort } from './questions/clack-prompt-port.ts'
export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts'
@@ -58,17 +58,38 @@ export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env):
/** Node child-process command runner with inherited stdio and quiescent completion. */
export class NodeCommandRunner implements CommandRunner {
/** Spawn one child and settle only after its exit. */
private readonly output: NodeJS.WritableStream | undefined
/**
* @param output - redirect target for child stdout+stderr; the child inherits
* this process's stdio when absent. Callers whose own stdout carries a machine
* protocol (create-sdk --json NDJSON) redirect child output to keep the
* protocol stream pure.
*/
constructor(output?: NodeJS.WritableStream) {
this.output = output
}
/** Spawn one child and settle only after exit, with redirected stdio drained. */
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const output = this.output
if (output === undefined) {
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
child.once('error', reject)
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
return
}
const child = spawn(command, [...args], {
cwd,
env: scrubEnvironment(),
stdio: 'inherit',
stdio: ['inherit', 'pipe', 'pipe'],
shell: false,
})
child.stdout.pipe(output, { end: false })
child.stderr.pipe(output, { end: false })
child.once('error', reject)
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
})
}
}
@@ -148,6 +169,25 @@ export abstract class PackageManager {
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
}
/**
* Build add-dependency command arguments for one already-normalized source spec.
* @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`).
* @returns arguments following the manager executable.
*/
addCommand(spec: string): readonly string[] {
return ['add', spec]
}
/**
* Add one dependency from a native source spec and fail on non-zero or signalled exit.
* @param spec - a package-manager-native dependency source.
* @param cwd - project directory.
* @param runner - optional subprocess boundary.
*/
async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
await this.runChecked(runner, this.addCommand(spec), cwd, 'add')
}
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
const result = await runner.run(this.name, args, cwd)
if (result.signal !== null) {
@@ -184,6 +224,11 @@ export class NpmPackageManager extends PackageManager {
override linkSpec(relativePath: string): string {
return `file:${relativePath}`
}
/** npm adds a dependency through `install <spec>` rather than an `add` verb. */
override addCommand(spec: string): readonly string[] {
return ['install', spec]
}
}
/** pnpm workspace behavior. */
@@ -220,6 +220,23 @@ export class ProjectEditSession implements FeatureProjectView {
this.addedPlugins.add(entry.id)
}
/**
* Mount a Cordis entry for an external dependency the package manager has already
* added (github or npm), without generating files or re-adding the dependency.
* @param id - stable Cordis config entry id.
* @param packageName - the installed dependency's package name.
*/
addExternalPlugin(id: string, packageName: string): void {
this.assertOpen()
if (!this.manifest().npmDependency(packageName)) {
throw new Error(`external plugin dependency is not installed: ${packageName}`)
}
const cordis = this.cordis()
if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`)
cordis.addEntry({ id, name: packageName })
this.addedPlugins.add(id)
}
/** Enable or disable one custom/manual Cordis config entry by stable id. */
setCustomPluginDisabled(id: string, disabled: boolean): void {
this.assertOpen()
@@ -0,0 +1,97 @@
/**
* Non-interactive prompt port for headless create/config and skill-driven runs.
*
* @module @deepseek-ai/dsh-helper/questions/headless-prompt-port
*/
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
PromptPort,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from './prompt-port.ts'
/**
* Raised when a headless run reaches a decision that was neither prefilled nor
* carries a usable default. The message names the unanswered prompt so an agent
* or CI caller can see exactly which input the spec must supply.
*/
export class HeadlessPromptError extends Error {
/** The unanswered prompt's user-facing message. */
readonly prompt: string
/** Build an error naming the unanswered prompt. */
constructor(prompt: string) {
super(`headless run needs an answer for: ${prompt}`)
this.name = 'HeadlessPromptError'
this.prompt = prompt
}
}
/** Resolve an answered outcome. */
function answered<T>(value: T): Promise<PromptOutcome<T>> {
return Promise.resolve({ status: 'answered', value })
}
/** Reject with a named unanswered-prompt error. */
function unanswered<T>(message: string): Promise<PromptOutcome<T>> {
return Promise.reject(new HeadlessPromptError(message))
}
/**
* A {@link PromptPort} that never blocks on a terminal.
*
* Answers are expected to arrive as prefilled values through the `Question` /
* `FeatureConfigurator` layers, so in a fully specified run this port is never
* reached. When it *is* reached, it takes the prompt's own declared default
* (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with
* {@link HeadlessPromptError}. Nested feature selection has no scalar default,
* so it always fails loud — headless callers must supply the feature set through
* the spec rather than the tree picker.
*/
export class HeadlessPromptPort implements PromptPort {
/** Answer visible text from its default, or fail loud. */
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
const fallback = request.initialValue ?? request.defaultValue
if (fallback === undefined) return unanswered(request.message)
const diagnostic = request.validate?.(fallback)
if (diagnostic) return unanswered(`${request.message} (${diagnostic})`)
return answered(fallback)
}
/** A secret has no safe default: always fail loud. */
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return unanswered(request.message)
}
/** Answer a single choice from its initial value, or fail loud. */
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Answer a multi-choice from its initial values, or fail loud when required. */
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
const initial = request.initialValues ?? []
if (request.required && initial.length === 0) return unanswered(request.message)
return answered(initial)
}
/** Answer a confirmation from its initial value, or fail loud. */
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Nested feature selection has no scalar default: always fail loud. */
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return unanswered(request.message)
}
}
@@ -1,6 +1,7 @@
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Writable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts'
import { EnvFile } from '../src/documents/env-file.ts'
@@ -299,6 +300,11 @@ describe('package manager strategies', () => {
await npm.install('/tmp', runner)
await npm.build('/tmp', runner)
expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']])
await npm.add('some-pkg@1.0.0', '/tmp', runner)
const pnpm = createPackageManager('pnpm', '10.0.0')
await pnpm.add('github:o/r#sha', '/tmp', runner)
expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0'])
expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha'])
const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
@@ -321,6 +327,19 @@ describe('package manager strategies', () => {
const runner = new NodeCommandRunner()
await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null })
await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow()
let redirected = ''
const output = new Writable({
write(chunk, _encoding, callback) { redirected += String(chunk); callback() },
})
const redirecting = new NodeCommandRunner(output)
await expect(redirecting.run(
process.execPath,
['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'],
root,
)).resolves.toEqual({ exitCode: 0, signal: null })
expect(redirected).toContain('child-out')
expect(redirected).toContain('child-err')
await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow()
})
it('discovers and rewrites a repository-local NPM dependency closure', async () => {
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
/** Unwrap an answered outcome or fail the test. */
async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
const outcome = await promise
if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
return outcome.value
}
describe('HeadlessPromptError', () => {
it('names the unanswered prompt', () => {
const error = new HeadlessPromptError('DeepSeek API key')
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('HeadlessPromptError')
expect(error.prompt).toBe('DeepSeek API key')
expect(error.message).toContain('DeepSeek API key')
})
})
describe('HeadlessPromptPort', () => {
const port = new HeadlessPromptPort()
describe('text', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
})
it('falls back to the default value', async () => {
expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
})
it('prefers the initial value over the default value', async () => {
expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
})
it('fails loud when no default exists', async () => {
await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
})
it('fails loud when the default is invalid', async () => {
await expect(port.text({
message: 'name',
defaultValue: '',
validate: value => value.length === 0 ? 'required' : undefined,
})).rejects.toThrow(/required/)
})
})
describe('secret', () => {
it('always fails loud', async () => {
await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
})
})
describe('select', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
})
it('fails loud without an initial value', async () => {
await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
})
})
describe('multiselect', () => {
it('returns the initial values', async () => {
expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
})
it('returns an empty selection when none are supplied and none are required', async () => {
expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
})
it('fails loud when required and nothing is preselected', async () => {
await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
})
})
describe('confirm', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
})
it('fails loud without an initial value', async () => {
await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
})
})
describe('nestedMultiselect', () => {
it('always fails loud', async () => {
await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
})
})
})
+22
View File
@@ -709,6 +709,28 @@ describe('SdkProject and ProjectEditSession', () => {
expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
})
it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-'))
temporary.push(root)
const creation = request()
const project = SdkProject.create(root, creation)
const registry = createBuiltinRegistry(project.profile)
const edit = project.edit(registry)
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
await edit.commit()
const manifestPath = join(root, 'package.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record<string, string> }
manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' }
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
const reopened = await SdkProject.open(root)
const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile))
edit2.addExternalPlugin('ext-plugin', 'ext-plugin')
expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists')
expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed')
const commit = await edit2.commit()
expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin')
})
})
describe('extension points', () => {
@@ -450,4 +450,35 @@ describe('feature configurator', () => {
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
.rejects.toThrow('has no default option')
})
it('configures fully from prefilled options, values, and secrets without prompting', async () => {
const registry = createBuiltinRegistry(profile)
const port = new QueuePromptPort([])
const result = await new FeatureConfigurator(port).configure(
registry.get(featureId('provider')),
profile,
undefined,
['custom'],
{ apiKey: 'prefilled-key' },
{ baseURL: 'https://prefilled' },
)
expect(result).toMatchObject({
options: ['custom'],
values: { baseURL: 'https://prefilled' },
secrets: { apiKey: 'prefilled-key' },
})
expect(port.requests).toEqual([])
})
it('rejects a non-string prefilled feature value', async () => {
const registry = createBuiltinRegistry(profile)
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
registry.get(featureId('provider')),
profile,
undefined,
['custom'],
{ apiKey: 'k' },
{ baseURL: 123 },
)).rejects.toThrow('must be a string')
})
})