@fooksky/lodestar 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/cordis.patch.yml +6 -0
- package/docs/branch-management.md +60 -0
- package/docs/design.md +342 -0
- package/docs/proposal.md +129 -0
- package/docs/research/agent-presets.md +99 -0
- package/docs/research/development-mode.md +98 -0
- package/docs/research/labwarden-session-cost.md +91 -0
- package/docs/research/workflow-survey.md +136 -0
- package/icon.svg +1 -0
- package/index.js +31 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +45 -4
- package/skills/dev-workflow/SKILL.md +35 -0
- package/skills/dev-workflow/references/delegation.md +23 -0
- package/skills/dev-workflow/references/execute.md +13 -0
- package/skills/dev-workflow/references/intent.md +9 -0
- package/skills/dev-workflow/references/plan.md +11 -0
- package/skills/dev-workflow/references/recover.md +13 -0
- package/skills/dev-workflow/references/spec.md +9 -0
- package/skills/dev-workflow/references/workspace.md +33 -0
- package/src/preset.js +37 -0
- package/src/schema.js +134 -0
- package/src/state.js +148 -0
- package/src/store.js +111 -0
- package/src/tool.js +52 -0
- package/src/workspace.js +93 -0
- package/tests/scenarios.md +60 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createState, applyOperations, DomainError } from './state.js';
|
|
3
|
+
import { parseState } from './schema.js';
|
|
4
|
+
import { requireWorkspace, workspaceStatus } from './workspace.js';
|
|
5
|
+
|
|
6
|
+
export const digestText = text => createHash('sha256').update(text.replace(/\r\n?/g, '\n')).digest('hex');
|
|
7
|
+
const fail = (code, message) => { throw new DomainError(code, message); };
|
|
8
|
+
const kinds = ['intent', 'spec', 'plan'];
|
|
9
|
+
|
|
10
|
+
// All project IO belongs to the caller's host filesystem, never Node fs.
|
|
11
|
+
export class ChangeStore {
|
|
12
|
+
constructor({ fs, cwd, signal, sandboxPolicy, workspace = { kind: 'directory' } }) {
|
|
13
|
+
Object.assign(this, { fs, cwd, signal, sandboxPolicy, workspace });
|
|
14
|
+
}
|
|
15
|
+
async resolve(path, parent) {
|
|
16
|
+
if (parent && (await this.fs.lstat(path, { cwd: this.cwd }, this.signal))?.type === 'symlink') fail('INVALID_REFERENCE', `Material paths cannot be symbolic links: ${path}`);
|
|
17
|
+
const target = await this.fs.resolve(path, { cwd: this.cwd, signal: this.signal });
|
|
18
|
+
if (parent && !this.fs.contains(parent, target)) fail('INVALID_REFERENCE', `Path escapes its material directory: ${path}`);
|
|
19
|
+
return target;
|
|
20
|
+
}
|
|
21
|
+
async paths(projectRoot, id) {
|
|
22
|
+
if (id !== undefined && (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id) || id === 'archive')) fail('INVALID_REFERENCE', 'changeId must be one safe path segment (archive is reserved).');
|
|
23
|
+
const root = await this.resolve(projectRoot);
|
|
24
|
+
// Resolve relative to the backend's process path, not its opaque target key.
|
|
25
|
+
const basePath = this.fs.processPath(root);
|
|
26
|
+
const materials = await this.resolve(`${basePath}/.dev-docs`, root);
|
|
27
|
+
const directory = await this.resolve(`${this.fs.processPath(materials)}/changes`, materials);
|
|
28
|
+
if (id === undefined) return { root, directory };
|
|
29
|
+
const change = await this.resolve(`${this.fs.processPath(directory)}/${id}`, directory);
|
|
30
|
+
const state = await this.resolve(`${this.fs.processPath(change)}/state.json`, change);
|
|
31
|
+
return { root, directory, change, state };
|
|
32
|
+
}
|
|
33
|
+
async stableRead(target) {
|
|
34
|
+
const before = await this.fs.stat(target, this.signal);
|
|
35
|
+
if (!before) fail('NOT_FOUND', `Missing file: ${target.displayPath}`);
|
|
36
|
+
const text = await this.fs.readText(target, this.signal);
|
|
37
|
+
const after = await this.fs.stat(target, this.signal);
|
|
38
|
+
if (!after || before.version !== after.version) fail('REVISION_CONFLICT', 'File changed while reading; get it again.');
|
|
39
|
+
return { text, version: after.version };
|
|
40
|
+
}
|
|
41
|
+
async readState(target, id) {
|
|
42
|
+
const { text, version } = await this.stableRead(target);
|
|
43
|
+
let raw;
|
|
44
|
+
try { raw = JSON.parse(text); } catch (cause) { throw new DomainError('INVALID_STATE', `Invalid state JSON: ${cause.message}`); }
|
|
45
|
+
const state = parseState(raw);
|
|
46
|
+
if (state.id !== id) fail('INVALID_STATE', 'State ID does not match its directory.');
|
|
47
|
+
return { state, version };
|
|
48
|
+
}
|
|
49
|
+
async documents(change, wanted = kinds) {
|
|
50
|
+
const documents = {};
|
|
51
|
+
for (const kind of wanted) {
|
|
52
|
+
const target = await this.resolve(`${this.fs.processPath(change)}/${kind}.md`, change);
|
|
53
|
+
if (!await this.fs.stat(target, this.signal)) continue;
|
|
54
|
+
const { text } = await this.stableRead(target);
|
|
55
|
+
documents[kind] = { path: `${kind}.md`, digest: digestText(text) };
|
|
56
|
+
}
|
|
57
|
+
return documents;
|
|
58
|
+
}
|
|
59
|
+
async publish(target, state, expected) {
|
|
60
|
+
try {
|
|
61
|
+
await this.fs.writeText(target, `${JSON.stringify(state, null, 2)}\n`, expected, this.signal, this.sandboxPolicy);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.code === 'FS_NOT_OBSERVED') fail('ALREADY_EXISTS', 'Change already exists; get its state.');
|
|
64
|
+
if (error.code === 'FS_STALE_VERSION') fail('REVISION_CONFLICT', 'State changed before publication; get and merge again.');
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async create({ projectRoot, changeId, title }) {
|
|
69
|
+
const { state: target, change } = await this.paths(projectRoot, changeId);
|
|
70
|
+
if (await this.fs.stat(change, this.signal)) fail('ALREADY_EXISTS', 'Change directory already exists. Use get for an existing change or choose a new ID.');
|
|
71
|
+
const state = createState({ id: changeId, title }, { workspace: this.workspace });
|
|
72
|
+
await this.publish(target, state, { kind: 'createIfAbsent' });
|
|
73
|
+
return { id: state.id, revision: state.revision, changed: true, summary: ['Created change.'] };
|
|
74
|
+
}
|
|
75
|
+
async get({ projectRoot, changeId, section }) {
|
|
76
|
+
const paths = await this.paths(projectRoot, changeId);
|
|
77
|
+
const { state } = await this.readState(paths.state, changeId);
|
|
78
|
+
const documents = await this.documents(paths.change, Object.keys(state.documents));
|
|
79
|
+
const consistency = Object.entries(state.documents).map(([kind, registered]) => ({ kind, status: !documents[kind] ? 'missing' : documents[kind].digest === registered.digest ? 'current' : 'changed' }));
|
|
80
|
+
return { id: state.id, revision: state.revision, bindingStatus: workspaceStatus(this.workspace, state.workspace), binding: state.workspace, ...(section ? { data: state[section] ?? null } : { state }), consistency, blockers: state.blockers.filter(blocker => blocker.status === 'open') };
|
|
81
|
+
}
|
|
82
|
+
async update({ projectRoot, changeId, expectedRevision, operations }) {
|
|
83
|
+
const paths = await this.paths(projectRoot, changeId);
|
|
84
|
+
const { state, version } = await this.readState(paths.state, changeId);
|
|
85
|
+
if (state.revision !== expectedRevision) fail('REVISION_CONFLICT', `Expected revision ${expectedRevision}; current revision is ${state.revision}.`);
|
|
86
|
+
const binding = operations.length === 1 && operations[0].type === 'bind-workspace';
|
|
87
|
+
if (!binding) requireWorkspace(this.workspace, state.workspace);
|
|
88
|
+
const documents = binding ? {} : await this.documents(paths.change);
|
|
89
|
+
const result = applyOperations(state, operations, { expectedRevision, documents, workspace: this.workspace });
|
|
90
|
+
if (result.changed) await this.publish(paths.state, result.state, { kind: 'replaceIfVersion', version });
|
|
91
|
+
const { state: updated, ...receipt } = result;
|
|
92
|
+
return receipt;
|
|
93
|
+
}
|
|
94
|
+
async list({ projectRoot, limit = 20, cursor }) {
|
|
95
|
+
const { directory } = await this.paths(projectRoot);
|
|
96
|
+
if (!await this.fs.stat(directory, this.signal)) return { items: [] };
|
|
97
|
+
const entries = (await this.fs.listDir(directory, this.signal)).filter(entry => entry.type === 'directory' && entry.name !== 'archive' && (!cursor || entry.name > cursor)).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
98
|
+
const items = [];
|
|
99
|
+
for (const entry of entries.slice(0, limit)) {
|
|
100
|
+
try {
|
|
101
|
+
const { state: target } = await this.paths(projectRoot, entry.name);
|
|
102
|
+
const { state } = await this.readState(target, entry.name);
|
|
103
|
+
items.push({ id: state.id, title: state.title, phase: state.workflow.phase, status: state.workflow.status, revision: state.revision, workspace: state.workspace, bindingStatus: workspaceStatus(this.workspace, state.workspace) });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!(error instanceof DomainError)) throw error;
|
|
106
|
+
items.push({ id: entry.name, error: { code: error.code, message: error.message } });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { items, ...(entries.length > limit ? { cursor: entries[limit - 1].name } : {}) };
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/tool.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { inputSchema, outputSchema, DomainError } from './schema.js';
|
|
3
|
+
import { ChangeStore } from './store.js';
|
|
4
|
+
import { inspectWorkspace } from './workspace.js';
|
|
5
|
+
|
|
6
|
+
// DSH canonical output supports a structural JSON Schema subset. Zod keeps
|
|
7
|
+
// refinements at the execution boundary; never pass library metadata to Host.
|
|
8
|
+
export function hostSchema(schema) {
|
|
9
|
+
const project = node => {
|
|
10
|
+
if (Array.isArray(node.type)) return { oneOf: node.type.map(type => project({ ...node, type })) };
|
|
11
|
+
const result = {};
|
|
12
|
+
for (const key of ['type', 'required', 'enum', 'const', 'description', 'title']) if (node[key] !== undefined) result[key] = node[key];
|
|
13
|
+
if (node.properties) result.properties = Object.fromEntries(Object.entries(node.properties).map(([key, value]) => [key, project(value)]));
|
|
14
|
+
if (node.items) result.items = project(node.items);
|
|
15
|
+
if (node.additionalProperties !== undefined) result.additionalProperties = typeof node.additionalProperties === 'boolean' ? node.additionalProperties : project(node.additionalProperties);
|
|
16
|
+
if (node.oneOf || node.anyOf) result.oneOf = (node.oneOf ?? node.anyOf).map(project);
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
return project(JSON.parse(JSON.stringify(z.toJSONSchema(schema, { target: 'draft-7' }))));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createTool(ctx) {
|
|
23
|
+
return {
|
|
24
|
+
name: 'dev_workflow',
|
|
25
|
+
description: 'Inspect the current workspace and atomically maintain branch-local change state in .dev-docs. projectRoot must belong to the calling Agent checkout. Load dev-workflow for workflow semantics. create binds the current branch; get/list expose bindingStatus; an intentional branch rename or reassignment uses a separate update with bind-workspace and reason. User decisions require real sources. This tool does not authorize implementation or schedule tasks. On revision conflicts, get and merge before retrying.',
|
|
26
|
+
parameters: { type: 'object', ...hostSchema(inputSchema) },
|
|
27
|
+
output: {
|
|
28
|
+
schema: hostSchema(outputSchema),
|
|
29
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
30
|
+
},
|
|
31
|
+
async execute(raw, exec) {
|
|
32
|
+
const parsed = inputSchema.safeParse(raw);
|
|
33
|
+
if (!parsed.success) throw new DomainError('INVALID_STATE', parsed.error.message);
|
|
34
|
+
const args = parsed.data;
|
|
35
|
+
if (!exec.agent) throw new Error('dev_workflow requires a calling Agent and session.');
|
|
36
|
+
exec.signal?.throwIfAborted();
|
|
37
|
+
const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal);
|
|
38
|
+
const policyService = ctx.get('sandboxPolicy');
|
|
39
|
+
if (ctx.fs.sandboxMode !== undefined && !policyService) throw new Error('dev_workflow: sandboxed filesystem requires sandboxPolicy.');
|
|
40
|
+
const sandboxPolicy = policyService?.resolve({ session: exec.agent.session });
|
|
41
|
+
try {
|
|
42
|
+
const workspace = await inspectWorkspace(ctx, { cwd, projectRoot: args.projectRoot, signal: exec.signal, sandboxPolicy });
|
|
43
|
+
const store = new ChangeStore({ fs: ctx.fs, cwd, signal: exec.signal, sandboxPolicy, workspace });
|
|
44
|
+
const result = args.action === 'workspace' ? {} : await store[args.action](args);
|
|
45
|
+
return outputSchema.parse({ action: args.action, workspace, ...result });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof DomainError) error.message = `${error.code}: ${error.message}`;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { DomainError } from './schema.js';
|
|
2
|
+
|
|
3
|
+
const fail = (code, message) => { throw new DomainError(code, message); };
|
|
4
|
+
const sameTarget = (fs, a, b) => fs.contains(a, b) && fs.contains(b, a);
|
|
5
|
+
// Prevent ambient Git overrides from redirecting queries into another checkout.
|
|
6
|
+
const gitEnv = Object.fromEntries([
|
|
7
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
|
8
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
9
|
+
'GIT_NAMESPACE', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT',
|
|
10
|
+
].map(key => [key, undefined]));
|
|
11
|
+
|
|
12
|
+
export async function inspectWorkspace(ctx, { cwd, projectRoot, signal, sandboxPolicy }) {
|
|
13
|
+
const fs = ctx.fs;
|
|
14
|
+
const caller = await fs.resolve(cwd, { signal });
|
|
15
|
+
const project = await fs.resolve(projectRoot, { cwd, signal });
|
|
16
|
+
if ((await fs.stat(project, signal))?.type !== 'directory') fail('INVALID_WORKSPACE', 'projectRoot must be an existing directory.');
|
|
17
|
+
if (!ctx.subprocess) fail('WORKSPACE_UNAVAILABLE', 'Workspace discovery requires the host subprocess service and Git.');
|
|
18
|
+
const subprocess = ctx.subprocess;
|
|
19
|
+
const executable = await subprocess.resolveExecutable('git', undefined, signal);
|
|
20
|
+
const sandbox = ctx.get('sandbox');
|
|
21
|
+
if (sandboxPolicy && sandboxPolicy.mode !== 'danger-full-access' && !sandbox) fail('WORKSPACE_UNAVAILABLE', 'Confined Git discovery requires the host sandbox service.');
|
|
22
|
+
const query = async (directory, args) => {
|
|
23
|
+
const querySignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(10000)]);
|
|
24
|
+
let argv = [executable, '--no-optional-locks', '-c', 'core.fsmonitor=false', ...args];
|
|
25
|
+
if (sandboxPolicy && sandboxPolicy.mode !== 'danger-full-access') argv = (await sandbox.confine(argv, sandboxPolicy, querySignal)).argv;
|
|
26
|
+
const handle = subprocess.spawn({
|
|
27
|
+
argv, cwd: directory, env: { ...gitEnv, LC_ALL: 'C', LANG: 'C', GIT_TERMINAL_PROMPT: '0' },
|
|
28
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } },
|
|
29
|
+
graceMs: 1000, signal: querySignal,
|
|
30
|
+
});
|
|
31
|
+
const outcome = await handle.done;
|
|
32
|
+
querySignal.throwIfAborted();
|
|
33
|
+
const stdout = handle.collected.stdout.readFrom(0), stderr = handle.collected.stderr.readFrom(0);
|
|
34
|
+
if (stdout.lossy || stderr.lossy) fail('GIT_QUERY_FAILED', 'Git workspace output was truncated.');
|
|
35
|
+
if (outcome.signal || outcome.exitCode === null) fail('GIT_QUERY_FAILED', `Git query terminated: ${outcome.signal ?? 'unknown outcome'}`);
|
|
36
|
+
return { code: outcome.exitCode, out: stdout.text.replace(/\r?\n$/, ''), err: stderr.text.trim() };
|
|
37
|
+
};
|
|
38
|
+
const checked = result => {
|
|
39
|
+
if (result.code !== 0) fail('GIT_QUERY_FAILED', `Git query failed (${result.code}): ${result.err || result.out}`);
|
|
40
|
+
return result.out;
|
|
41
|
+
};
|
|
42
|
+
const identify = async target => {
|
|
43
|
+
const directory = fs.processPath(target);
|
|
44
|
+
const probe = await query(directory, ['rev-parse', '--is-bare-repository']);
|
|
45
|
+
if (probe.code === 128 && probe.err === 'fatal: not a git repository (or any of the parent directories): .git') return { kind: 'directory' };
|
|
46
|
+
const bare = checked(probe);
|
|
47
|
+
if (bare === 'true') fail('INVALID_WORKSPACE', 'A bare repository has no development checkout.');
|
|
48
|
+
if (bare !== 'false') fail('GIT_QUERY_FAILED', 'Unexpected Git repository probe output.');
|
|
49
|
+
const top = checked(await query(directory, ['rev-parse', '--show-toplevel']));
|
|
50
|
+
const root = await fs.resolve(top, { cwd: directory, signal });
|
|
51
|
+
return { kind: 'git', root };
|
|
52
|
+
};
|
|
53
|
+
const current = await identify(project);
|
|
54
|
+
const calling = sameTarget(fs, caller, project) ? current : await identify(caller);
|
|
55
|
+
if (current.kind !== calling.kind || (current.kind === 'git' && !sameTarget(fs, current.root, calling.root))) fail('WORKSPACE_MISMATCH', 'Agent cwd and projectRoot must belong to the same checkout. Enter the intended worktree before accessing its materials.');
|
|
56
|
+
if (current.kind === 'directory') {
|
|
57
|
+
if (!fs.contains(caller, project)) fail('WORKSPACE_MISMATCH', 'A non-Git projectRoot must be inside the Agent working directory.');
|
|
58
|
+
return { kind: 'directory', projectRoot: fs.processPath(project) };
|
|
59
|
+
}
|
|
60
|
+
const directory = fs.processPath(project);
|
|
61
|
+
const projectPath = checked(await query(directory, ['rev-parse', '--show-prefix']));
|
|
62
|
+
const symbolic = await query(directory, ['symbolic-ref', '--quiet', 'HEAD']);
|
|
63
|
+
let branch = null;
|
|
64
|
+
if (symbolic.code === 0 && symbolic.out.startsWith('refs/heads/')) branch = symbolic.out.slice('refs/heads/'.length);
|
|
65
|
+
else if (symbolic.code !== 1 || symbolic.err) checked({ ...symbolic, code: symbolic.code || 128 });
|
|
66
|
+
const head = await query(directory, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
|
|
67
|
+
let headCommit = null;
|
|
68
|
+
if (head.code === 0 && /^[a-f0-9]{40}([a-f0-9]{24})?$/.test(head.out)) headCommit = head.out;
|
|
69
|
+
else {
|
|
70
|
+
// A named ref that does not exist is unborn; corrupt/existing non-commit refs are errors.
|
|
71
|
+
if (!branch || head.code !== 1 || head.err) checked({ ...head, code: head.code || 128 });
|
|
72
|
+
const ref = await query(directory, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
73
|
+
if (ref.code !== 1 || ref.err) fail('GIT_QUERY_FAILED', `HEAD does not resolve to a commit: ${ref.err || head.err || branch}`);
|
|
74
|
+
}
|
|
75
|
+
return { kind: 'git', projectRoot: directory, checkoutRoot: fs.processPath(current.root), projectPath, branch, headCommit };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function workspaceStatus(current, binding) {
|
|
79
|
+
if (current.kind === 'git' && current.branch === null) return 'detached';
|
|
80
|
+
if (binding.kind !== current.kind) return 'mismatch';
|
|
81
|
+
return binding.kind === 'directory' || (binding.branch === current.branch && binding.projectPath === current.projectPath) ? 'matched' : 'mismatch';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function bindWorkspace(current) {
|
|
85
|
+
if (current.kind === 'directory') return { kind: 'directory' };
|
|
86
|
+
if (current.branch === null) fail('WORKSPACE_DETACHED', 'A named branch is required to create, bind or update a change.');
|
|
87
|
+
return { kind: 'git', branch: current.branch, baseCommit: current.headCommit, projectPath: current.projectPath };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function requireWorkspace(current, binding) {
|
|
91
|
+
const status = workspaceStatus(current, binding);
|
|
92
|
+
if (status !== 'matched') fail(status === 'detached' ? 'WORKSPACE_DETACHED' : 'WORKSPACE_MISMATCH', `Workspace binding is ${status}. Read the change and use a separate bind-workspace update with a reason only when reassignment is intended.`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# 开发工作流验证与行为验收记录
|
|
2
|
+
|
|
3
|
+
程序验证和真实模型会话观察分别记录。`npm test` 的通过不等于以下模型场景已通过;本次没有安装 profile、切换现有会话或启动替代 GUI。
|
|
4
|
+
|
|
5
|
+
## 首版已完成的可重复程序验证(历史记录)
|
|
6
|
+
|
|
7
|
+
2026-11-07:使用已安装 DSH `0.2.2-rc.1`,设置 `DSH_HOST_ROOT` 后运行全套,26/26 通过、0 跳过。语法检查与包内容预览通过。
|
|
8
|
+
|
|
9
|
+
- 状态:确认复用/撤回、原子多操作、当前材料依赖、阶段约束、验收与关闭。
|
|
10
|
+
- 仓储:创建冲突、CAS 并发、正文变化检测、损坏/未知 schema、写入失败不覆盖。
|
|
11
|
+
- 调用边界:Agent 身份、调用者目录、会话 sandbox policy、取消信号透传。
|
|
12
|
+
- 真实 DSH 文件服务:相对项目根、原子并发、目录分页、符号链接路径边界。
|
|
13
|
+
- 真实沙箱:Session + sandboxPolicy + SandboxedFileSystem 验证 workspace-write、read-only、projectRoot 越界及当前目录变化不扩大授权根。
|
|
14
|
+
- 注册、资源和可选模式:真实 Cordis/SkillRegistry/ToolsService、Loader/AgentPresetRegistry 注册、卸载、scope 隔离、子 scope 继承和保留 revision 生命周期通过。模式测试的原生工具名称集合为夹具,未构造完整 Team 服务;不将该测试宣称为真实 Team 工具视图验收。
|
|
15
|
+
|
|
16
|
+
## 分支扩展已完成的程序验证
|
|
17
|
+
|
|
18
|
+
2026-11-07:使用已安装 DSH `0.2.2-rc.1`,设置 `DSH_HOST_ROOT` 后运行 `npm test`,**35/35 通过、0 跳过**;`npm run check`、`npm pack --dry-run` 和差异空白检查通过。未安装 profile、提交或推送。以下是实际自动验证的范围,与下方真实模型行为验收分开。
|
|
19
|
+
|
|
20
|
+
| 范围 | 已验证结果 | 证据 |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| linked worktree 隔离 | main 与两个 linked worktree 使用同一 change ID,状态独立推进;知识继承提交快照,修改互不影响 | [真实工作区测试](host-workspace.test.js) |
|
|
23
|
+
| 项目根边界 | 跨 checkout 的全部 action 被拒绝;同 checkout 项目子目录可创建/读取;子模块独立识别,从父仓库直接指向子模块被拒绝;普通目录要求位于 cwd 内 | [真实工作区测试](host-workspace.test.js)、[工作区单测](workspace.test.js) |
|
|
24
|
+
| 绑定与重绑 | 创建 schemaVersion: 2 且 workspace 必填的状态;提交推进不锁定,同绑定操作不增 revision、不刷新 baseCommit;分支改名显示 mismatch 并拒绝常规写,独立重绑保留旧绑定和理由;混合操作失败不发布 | [真实工作区测试](host-workspace.test.js) |
|
|
25
|
+
| 状态格式与 CAS | 仅接受 schemaVersion: 2 和必填 workspace;旧/未知 schema、缺失绑定在 get/list/bind 中均报错且不发布;缺失 state 的目录显示错误;错误 revision、原子并发与失败写入保护继续通过。简化后的真实宿主全套 35/35 通过 | [真实工作区测试](host-workspace.test.js)、[仓储测试](store.test.js)、[宿主仓储测试](host-store.test.js) |
|
|
26
|
+
| Git 状态及错误 | detached 可读且 create/update/bind 均拒绝;unborn 可写且 baseCommit null;bare、损坏 gitfile/HEAD 拒绝;缺少服务/Git、spawn 失败、取消、有损输出不降级 | [真实工作区测试](host-workspace.test.js)、[工作区单测](workspace.test.js) |
|
|
27
|
+
| 可移动绑定与合入历史 | 普通 checkout 搬目录后绑定仍匹配;change 材料合入 main 后来源绑定仍可读、常规写拒绝 | [真实工作区测试](host-workspace.test.js) |
|
|
28
|
+
| 真实宿主边界 | 使用实际 fs、subprocess、sandbox、Session 和注册器;read-only 允许读取并拒绝写入;工具/skill/schema 注册及卸载通过;nullable schema 已适配 Host 的 oneOf 子集 | [沙箱测试](host-sandbox.test.js)、[注册测试](registration.test.js) |
|
|
29
|
+
|
|
30
|
+
工作区单测通过绑定比较验证 HEAD 变化不参与身份判断;未另行重放完整 rebase、浅克隆、跨机器检出或真实 PR 服务。符号链接材料边界由既有真实文件服务测试覆盖。测试准备 Git 仓库可使用 Node child_process,插件生产路径的 Git 查询统一走宿主 subprocess/sandbox。
|
|
31
|
+
|
|
32
|
+
## 真实模型会话验收(尚未执行)
|
|
33
|
+
|
|
34
|
+
以下场景应在允许使用插件的隔离会话中观察;不能仅用文档检查宣布通过。无需自动安装到用户 profile。若部署环境未提供这些能力,记录具体阻断,不降低验收标准。
|
|
35
|
+
|
|
36
|
+
| 场景 | 执行与观察要点 | 判定 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| 增量顺序交付 | 两个独立需求分别建 change,确认→设计→授权→实施→实际验收;不自动合并范围 | 待观察 |
|
|
39
|
+
| spec 满足而 intent 不足 | 验证揭示真实需求未满足,回到相应材料并沿用明确的已有确认,不凭测试通过 close | 待观察 |
|
|
40
|
+
| 多轮变更 | intent/spec/plan 反复修订、局部验证失效与有效证据沿用;无旧版本授权串用 | 待观察 |
|
|
41
|
+
| 空项目到 MVP | 按真实可运行切片完成,不以脚手架或假 fixture 冒充产品集成 | 待观察 |
|
|
42
|
+
| 只设计 | 用户要求只设计,交付 spec 后停止,不建实施 plan/goal、不写产品代码 | 待观察 |
|
|
43
|
+
| 确认复用 | 同一句批准需求、方案和实施,不再重复询问;“先别做”不得登记实施授权 | 待观察 |
|
|
44
|
+
| Team 协作 | 原生 task revision/owner/write scopes;成员只做任务包,Lead 持有主材料;inactive 不当 completed | 待观察 |
|
|
45
|
+
| 中断恢复 | 正文先写而状态未登记、回执未知、旧 Team 不可达;只补缺口,不全量重跑 | 待观察 |
|
|
46
|
+
| 浏览器缺失 | 证明替代验证覆盖同一命题,或明确验证未完成,不以编译通过替代交互验收 | 待观察 |
|
|
47
|
+
| 模式生命周期 | 新会话选择、子成员职责、停用后贡献移除、既有有效 change 恢复;普通会话不受影响 | 部分可由宿主集成测试覆盖,模型部分待观察 |
|
|
48
|
+
|
|
49
|
+
### 分支扩展真实行为观察(尚未执行)
|
|
50
|
+
|
|
51
|
+
| 场景 | 执行与观察要点 | 判定 |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| 外部工作区准备 | 外部助手领取 Issue、选择独立 worktree 和启动会话;模型先 workspace 再 create,不让插件创建分支、跨需求调度或扫描其他工作区;普通调查可不建 change | 待观察 |
|
|
54
|
+
| 本地材料与委派 | 每个需求的 Lead 和成员使用本 worktree 的代码、知识、主材料及证据;任务包包含 projectRoot/changeId/预期分支/cwd;不共享材料根或复制后双写 | 待观察 |
|
|
55
|
+
| 分支漂移恢复 | mismatch 先辨认归属,不凭相同 HEAD 猜测;分支改名、明确接管或普通目录初始化为仓库后有依据地独立重绑,历史必含 previous;缺失绑定或非法 schema 报错,不能靠重绑修复;detached 不写,历史合入项不批量改绑 | 待观察 |
|
|
56
|
+
| 运行期间工作区稳定 | 不外部切分支、搬移或清理;同 checkout 不并发切换不同需求;CAS 不当任务锁,多 Lead 先明确交接 | 待观察 |
|
|
57
|
+
| PR/MR 交付边界 | plan/交付记录地址、源/目标分支、评审/合入结果和证据;要求合入时先集成验证,边界为提交 PR 时不声称合入;反馈沿用原有退回、版本及局部失效 | 待观察 |
|
|
58
|
+
| 语义集成与清理 | 知识和 state 冲突核对语义;保留需要的代码、材料及证据后按授权提交/清理;外部数据库、端口、账号等独立隔离,插件不自动接管 | 待观察 |
|
|
59
|
+
|
|
60
|
+
每次观察记录工作区/分支、材料版本、输入表达、实际产物、验收证据、失败/阻断和结果;耗时/token 仅在宿主提供时记录,不解析私有日志补造指标。
|