New:Socket for Asana Is Now Available.Learn more
Get Started

dsh-projects

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

dsh-projects - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+61
lib/worktree.js
import { spawnSync } from "node:child_process";
import { basename, dirname, join } from "node:path";
/**
* git worktree helpers. Worktrees are created as sibling directories of the
* project root (`<root>-<name>`), matching the common `git worktree add`
* convention, and their branch defaults to the worktree name.
*
* Errors surface as {@link WorktreeError} with a user-safe message; the git
* command runs with the project root as cwd so it must be inside a git
* repository/worktree.
*/
export class WorktreeError extends Error {}
/** Compute the sibling path for a worktree of the given project root. */
export function computeWorktreePath(root, name) {
return join(dirname(root), `${basename(root)}-${name}`);
}
function runGit(root, args) {
const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
if (result.error !== void 0) {
throw new WorktreeError(`failed to run git: ${String(result.error.code ?? result.error.message)}`);
}
return result;
}
/** Is `root` inside a git repository? Never throws — false on any error. */
export function isGitRepo(root) {
try {
return runGit(root, ["rev-parse", "--is-inside-work-tree"]).status === 0;
} catch {
return false;
}
}
/**
* Create an isolated git worktree for a project.
* @returns the created `{ path, branch }`.
*/
export function gitWorktreeAdd(root, name, branch = name) {
const path = computeWorktreePath(root, name);
const result = runGit(root, ["worktree", "add", "-b", branch, path]);
if (result.status !== 0) {
throw new WorktreeError(
`git worktree add failed: ${(result.stderr || result.stdout || "").trim() || "unknown error"}`
);
}
return { path, branch };
}
/** Remove a git worktree by its path. */
export function gitWorktreeRemove(root, path) {
const result = runGit(root, ["worktree", "remove", "--force", path]);
if (result.status !== 0) {
throw new WorktreeError(
`git worktree remove failed: ${(result.stderr || result.stdout || "").trim() || "unknown error"}`
);
}
}
+7
-3

@@ -5,7 +5,8 @@ # dsh-projects bundle patch.

# The row owns: the project store service, the `/project` command, and the
# per-session system-prompt injection of the project background context
# (default: on; toggle per session via `/project inject on|off`).
# per-session system-prompt injection of project context (instruction files,
# background, sub-task, worktree). Injection defaults to on; toggle per
# session via `/project inject on|off`.
#
# Users may override the config below from their profile's cordis.patch.yml
# by targeting the row id `projects`.
# by targeting the row id `projects` (config is replaced wholesale).
- insert:

@@ -18,1 +19,4 @@ - id: projects

autoBindDefault: true
instructionFiles: ['DEEPSEEK.md', 'AGENT.md']
maxInstructionBytes: 16384
retentionDays: 7

@@ -16,2 +16,12 @@ /**

* /project inject on|off -> per-session injection toggle
* /project task create <name> [说明] -> create a sub-task/thread
* /project task list -> list sub-tasks
* /project task switch <name> -> bind this session to a sub-task
* /project task close -> unbind this session's sub-task
* /project worktree add <name> [branch] -> create an isolated git worktree
* /project worktree list -> list worktrees
* /project worktree switch <name> -> bind this session to a worktree
* /project worktree remove <name> -> remove a git worktree
* /project archive [sessionId] -> archive a session (kept forever)
* /project sessions -> list project sessions
* /project delete <name> -> remove project state (files untouched)

@@ -21,18 +31,23 @@ */

const USAGE = [
"/project create <name> [背景说明] — 创建并绑定一个项目",
"/project open <name> — 绑定当前会话到已有项目",
"/project close — 解绑当前会话",
"/project list — 列出全部项目",
"/project status — 当前会话的项目与注入状态",
"/project desc [<name>] <背景说明> — 设置项目背景/目标说明",
"/project inject on|off — 本会话是否注入项目背景(默认开)",
"/project delete <name> — 删除项目状态(不删项目文件)"
"/project create <name> [背景说明] — 创建并绑定一个项目",
"/project open <name> — 绑定当前会话到已有项目",
"/project close — 解绑当前会话",
"/project list — 列出全部项目",
"/project status — 当前会话的项目与注入状态",
"/project desc [<name>] <背景说明> — 设置项目背景/目标说明",
"/project inject on|off — 本会话是否注入项目背景(默认开)",
"/project task create <name> [说明] — 在项目内创建子任务/线程",
"/project task list — 列出项目子任务",
"/project task switch <name> — 绑定当前会话到子任务",
"/project task close — 解除当前会话的子任务绑定",
"/project worktree add <name> [branch] — 创建隔离的 git worktree",
"/project worktree list — 列出项目 worktree",
"/project worktree switch <name> — 绑定当前会话到 worktree",
"/project worktree remove <name> — 移除 git worktree",
"/project archive [sessionId] — 归档会话(永久保留)",
"/project sessions — 列出项目会话与保留状态",
"/project delete <name> — 删除项目状态(不删项目文件)"
].join("\n");
/**
* Parse one `/project` invocation.
* @param rawInput - the raw command input after `/project`.
* @returns a discriminated parse result; `{kind:"error"}` messages are
* user-facing and safe to render directly.
*/
/** Parse one `/project` invocation. */
export function parseProjectCommand(rawInput) {

@@ -64,7 +79,6 @@ const input = (rawInput ?? "").trim();

case "status": return { kind: "status" };
case "sessions": return { kind: "sessions" };
case "desc": {
// `desc <name> <text...>` when a second token exists, else the whole
// input is the description for the current project. The handler
// falls back to "current project" when the first token is not a
// known project name.
// input is the description for the current project.
if (rest.length >= 2) {

@@ -82,2 +96,42 @@ return { kind: "desc", name: rest[0], text: rest.slice(1).join(" ") };

}
case "task": {
const sub = rest[0]?.toLowerCase();
if (sub === "create") {
const name = rest[1];
if (name === void 0) return { kind: "error", text: "Usage: /project task create <name> [说明]" };
return { kind: "taskCreate", name, description: rest.slice(2).join(" ") };
}
if (sub === "list") return { kind: "taskList" };
if (sub === "switch") {
const name = rest[1];
if (name === void 0) return { kind: "error", text: "Usage: /project task switch <name>" };
return { kind: "taskSwitch", name };
}
if (sub === "close") return { kind: "taskClose" };
return { kind: "error", text: "Usage: /project task create|list|switch|close" };
}
case "worktree": {
const sub = rest[0]?.toLowerCase();
if (sub === "add") {
const name = rest[1];
if (name === void 0) return { kind: "error", text: "Usage: /project worktree add <name> [branch]" };
return { kind: "worktreeAdd", name, branch: rest[2] };
}
if (sub === "list") return { kind: "worktreeList" };
if (sub === "switch") {
const name = rest[1];
if (name === void 0) return { kind: "error", text: "Usage: /project worktree switch <name>" };
return { kind: "worktreeSwitch", name };
}
if (sub === "remove") {
const name = rest[1];
if (name === void 0) return { kind: "error", text: "Usage: /project worktree remove <name>" };
return { kind: "worktreeRemove", name };
}
return { kind: "error", text: "Usage: /project worktree add|list|switch|remove" };
}
case "archive": {
const sessionId = rest[0];
return { kind: "archive", sessionId: sessionId === void 0 ? void 0 : sessionId };
}
case "delete": {

@@ -84,0 +138,0 @@ const name = rest[0];

import { ProjectError, ProjectStore } from "./service.js";
import { parseProjectCommand, USAGE } from "./grammar.js";
import { renderProjectContext } from "./inject.js";
import { renderProjectContext, DEFAULT_INSTRUCTION_FILES, DEFAULT_MAX_INSTRUCTION_BYTES } from "./inject.js";
import { resolveDshHome } from "./home.js";
import { WorktreeError, gitWorktreeAdd, gitWorktreeRemove, isGitRepo } from "./worktree.js";

@@ -11,7 +12,8 @@ /**

* - a `ctx.projects` service (store + per-session bindings),
* - the `/project` human command (create/open/close/list/status/desc/inject/
* delete),
* - the `/project` human command (project / task / worktree / session
* lifecycle),
* - a dynamic system-prompt context that injects the bound project's
* background/goal description into every session, toggleable per session
* (`/project inject on|off`, default on).
* instruction files (DEEPSEEK.md / AGENT.md), background/goal description,
* current sub-task and current git worktree into every session (default
* on, toggleable per session).
*

@@ -34,3 +36,9 @@ * Zero runtime dependencies: only the Cordis context handed to `apply` is

/** New sessions without an explicit binding inherit the default project. */
autoBindDefault: true
autoBindDefault: true,
/** Ordered instruction files loaded from the project root. */
instructionFiles: DEFAULT_INSTRUCTION_FILES,
/** Total byte budget across instruction files. */
maxInstructionBytes: DEFAULT_MAX_INSTRUCTION_BYTES,
/** Non-archived session records older than this many days are pruned. */
retentionDays: 7
};

@@ -52,8 +60,16 @@

/** Translate a caught error into a user-safe error reply. */
function errorReply(error) {
const message = error instanceof ProjectError || error instanceof WorktreeError
? error.message
: `操作失败: ${String(error?.message ?? error)}`;
return { kind: "error", text: message };
}
/** Format one project as a status card for command replies. */
function renderStatusCard(project, injectionEnabled) {
const sessions = Array.isArray(project.sessions) ? project.sessions.length : 0;
const last = Array.isArray(project.sessions) && project.sessions.length > 0
? project.sessions[project.sessions.length - 1].startedAt
: null;
function renderStatusCard(project, injectionEnabled, task, worktree, retentionDays) {
const sessions = Array.isArray(project.sessions) ? project.sessions : [];
const active = sessions.filter((s) => s.archived !== true).length;
const archived = sessions.length - active;
const last = sessions.length > 0 ? sessions[sessions.length - 1].startedAt : null;
const lines = [

@@ -63,3 +79,6 @@ `项目: ${project.name}`,

`背景与目标: ${project.description?.trim() || "(未设置,可用 /project desc <说明> 设置)"}`,
`会话数: ${sessions}${last ? `(最近: ${last})` : ""}`,
`子任务: ${project.tasks?.length ?? 0} 个${task ? `(当前: ${task})` : ""}`,
`worktree: ${project.worktrees?.length ?? 0} 个${worktree ? `(当前: ${worktree})` : ""}`,
`会话: ${active} 活跃 / ${archived} 已归档${last ? `(最近: ${last})` : ""}`,
`会话保留: ${retentionDays} 天(未归档自动清理,/project archive 永久保留)`,
`本会话注入: ${injectionEnabled ? "开" : "关"}`

@@ -70,17 +89,9 @@ ];

/** Translate a caught error into a user-safe error reply. */
function errorReply(error) {
const message = error instanceof ProjectError
? error.message
: `操作失败: ${String(error?.message ?? error)}`;
return { kind: "error", text: message };
}
/**
* Execute one `/project` invocation.
* @param state - plugin state: store + per-session binding/injection maps.
* @param state - plugin state: store + per-session binding maps.
* @param invocation - the command invocation carrying the receiving agent.
*/
function executeProjectCommand(state, invocation) {
const { store, bindings, injection, config } = state;
const { store, bindings, injection, sessionTask, sessionWorktree, config } = state;
const session = invocation.agent?.session;

@@ -98,6 +109,8 @@ const sessionId = session?.id;

try {
store.recordSession(name, sessionId);
store.recordSession(name, sessionId, {
task: sessionTask.get(sessionId),
worktree: sessionWorktree.get(sessionId)
});
} catch {
// project may have been deleted between get and bind — binding
// itself is what matters; recording is best-effort
// best-effort recording; binding itself is what matters
}

@@ -107,2 +120,17 @@ }

/** The current session's effective project name, or null. */
const requireProject = () => {
const name = effectiveBinding(rawBound, config.autoBindDefault, store);
if (name === null || name === void 0) {
return { kind: "error", text: "当前会话未绑定项目。先 /project open <name>。" };
}
return { kind: "ok", name };
};
/** Resolve a worktree name to its path (for injection/display). */
const worktreePathOf = (project, worktreeName) => {
const entry = project.worktrees?.find((wt) => wt.name === worktreeName);
return entry?.path ?? worktreeName;
};
switch (parsed.kind) {

@@ -134,6 +162,3 @@ case "help":

bind(parsed.name);
return {
kind: "success",
text: `已绑定项目: ${renderStatusCard(project, true)}`
};
return { kind: "success", text: `已绑定项目:\n${renderStatusCard(project, true, void 0, void 0, config.retentionDays)}` };
} catch (error) {

@@ -148,2 +173,4 @@ return errorReply(error);

injection.delete(sessionId);
sessionTask.delete(sessionId);
sessionWorktree.delete(sessionId);
}

@@ -161,3 +188,5 @@ return { kind: "success", text: "当前会话已与项目解绑(项目数据保留)。" };

const sessions = Array.isArray(entry.sessions) ? entry.sessions.length : 0;
return `- ${entry.name} (${entry.root}) [${sessions} 会话]${active}`;
const tasks = Array.isArray(entry.tasks) ? entry.tasks.length : 0;
const worktrees = Array.isArray(entry.worktrees) ? entry.worktrees.length : 0;
return `- ${entry.name} (${entry.root}) [${sessions} 会话 · ${tasks} 子任务 · ${worktrees} worktree]${active}`;
});

@@ -178,3 +207,8 @@ return { kind: "success", text: `项目列表:\n${lines.join("\n")}` };

const enabled = typeof sessionId === "string" ? (injection.get(sessionId) ?? true) : true;
return { kind: "success", text: renderStatusCard(project, enabled) };
const task = typeof sessionId === "string" ? sessionTask.get(sessionId) : void 0;
const wtName = typeof sessionId === "string" ? sessionWorktree.get(sessionId) : void 0;
return {
kind: "success",
text: renderStatusCard(project, enabled, task, worktreePathOf(project, wtName), config.retentionDays)
};
} catch (error) {

@@ -192,6 +226,3 @@ return errorReply(error);

if (target === null || target === void 0) {
return {
kind: "error",
text: "未指定项目。用法: /project desc <name> <背景说明>,或先 /project open <name> 再 /project desc <背景说明>。"
};
return { kind: "error", text: "未指定项目。用法: /project desc <name> <背景说明>,或先 /project open <name> 再 /project desc <背景说明>。" };
}

@@ -219,8 +250,187 @@ try {

injection.set(sessionId, parsed.enabled);
return {
kind: "success",
text: `本会话的项目背景注入已${parsed.enabled ? "开启" : "关闭"}。`
};
return { kind: "success", text: `本会话的项目背景注入已${parsed.enabled ? "开启" : "关闭"}。` };
}
// ---- tasks ----------------------------------------------------------
case "taskCreate": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
store.addTask(resolved.name, parsed.name, parsed.description);
if (typeof sessionId === "string") {
sessionTask.set(sessionId, parsed.name);
store.recordSession(resolved.name, sessionId, { task: parsed.name });
}
return { kind: "success", text: `子任务已创建并绑定当前会话: ${parsed.name}` };
} catch (error) {
return errorReply(error);
}
}
case "taskList": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
const project = store.get(resolved.name);
if (project.tasks.length === 0) {
return { kind: "success", text: "该项目还没有子任务。用 /project task create <name> [说明] 创建。" };
}
const current = typeof sessionId === "string" ? sessionTask.get(sessionId) : void 0;
const lines = project.tasks.map((task) => {
const mark = task.name === current ? " ← 本会话" : "";
return `- ${task.name}${task.description?.trim() ? ` — ${task.description.trim()}` : ""}${mark}`;
});
return { kind: "success", text: `子任务列表:\n${lines.join("\n")}` };
} catch (error) {
return errorReply(error);
}
}
case "taskSwitch": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
if (!store.hasTask(resolved.name, parsed.name)) {
return { kind: "error", text: `子任务 ${JSON.stringify(parsed.name)} 不存在。用 /project task list 查看。` };
}
if (typeof sessionId === "string") {
sessionTask.set(sessionId, parsed.name);
store.recordSession(resolved.name, sessionId, { task: parsed.name });
}
return { kind: "success", text: `当前会话已绑定子任务: ${parsed.name}` };
} catch (error) {
return errorReply(error);
}
}
case "taskClose": {
if (typeof sessionId === "string") {
sessionTask.delete(sessionId);
if (bound !== void 0) {
try {
store.recordSession(bound, sessionId, { task: null });
} catch {
// best-effort
}
}
}
return { kind: "success", text: "当前会话已解除子任务绑定。" };
}
// ---- worktrees ------------------------------------------------------
case "worktreeAdd": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
const project = store.get(resolved.name);
if (!isGitRepo(project.root)) {
return { kind: "error", text: `项目根目录 ${project.root} 不是 git 仓库,无法创建 worktree。` };
}
const branch = parsed.branch ?? parsed.name;
const { path } = gitWorktreeAdd(project.root, parsed.name, branch);
store.addWorktree(resolved.name, { name: parsed.name, path, branch });
return { kind: "success", text: `已创建隔离 worktree: ${parsed.name}\n路径: ${path}\n分支: ${branch}\n用 /project worktree switch ${parsed.name} 绑定当前会话。` };
} catch (error) {
return errorReply(error);
}
}
case "worktreeList": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
const project = store.get(resolved.name);
if (project.worktrees.length === 0) {
return { kind: "success", text: "该项目还没有 worktree。用 /project worktree add <name> [branch] 创建。" };
}
const current = typeof sessionId === "string" ? sessionWorktree.get(sessionId) : void 0;
const lines = project.worktrees.map((wt) => {
const mark = wt.name === current ? " ← 本会话" : "";
return `- ${wt.name} (${wt.path}) branch: ${wt.branch ?? "-"}${mark}`;
});
return { kind: "success", text: `worktree 列表:\n${lines.join("\n")}` };
} catch (error) {
return errorReply(error);
}
}
case "worktreeSwitch": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
if (!store.hasWorktree(resolved.name, parsed.name)) {
return { kind: "error", text: `worktree ${JSON.stringify(parsed.name)} 不存在。用 /project worktree list 查看。` };
}
if (typeof sessionId === "string") {
sessionWorktree.set(sessionId, parsed.name);
store.recordSession(resolved.name, sessionId, { worktree: parsed.name });
}
return { kind: "success", text: `当前会话已绑定 worktree: ${parsed.name}` };
} catch (error) {
return errorReply(error);
}
}
case "worktreeRemove": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
const project = store.get(resolved.name);
const wt = project.worktrees.find((entry) => entry.name === parsed.name);
if (wt === void 0) {
return { kind: "error", text: `worktree ${JSON.stringify(parsed.name)} 不存在。` };
}
gitWorktreeRemove(project.root, wt.path);
store.removeWorktree(resolved.name, parsed.name);
if (typeof sessionId === "string" && sessionWorktree.get(sessionId) === parsed.name) {
sessionWorktree.delete(sessionId);
store.recordSession(resolved.name, sessionId, { worktree: null });
}
return { kind: "success", text: `worktree ${parsed.name} 已移除。` };
} catch (error) {
return errorReply(error);
}
}
// ---- sessions -------------------------------------------------------
case "sessions": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
store.pruneSessions(resolved.name, config.retentionDays);
const project = store.get(resolved.name);
if (project.sessions.length === 0) {
return { kind: "success", text: "该项目还没有会话记录。" };
}
const lines = project.sessions.map((entry) => {
const mark = entry.id === sessionId ? " ← 本会话" : "";
const archived = entry.archived ? "已归档" : "保留中";
const task = entry.task ? ` · 子任务 ${entry.task}` : "";
const worktree = entry.worktree ? ` · ${entry.worktree}` : "";
return `- ${entry.id} (${entry.startedAt}) ${archived}${task}${worktree}${mark}`;
});
return { kind: "success", text: `会话列表(保留 ${config.retentionDays} 天,未归档自动清理):\n${lines.join("\n")}` };
} catch (error) {
return errorReply(error);
}
}
case "archive": {
const resolved = requireProject();
if (resolved.kind === "error") return resolved;
try {
const target = parsed.sessionId ?? sessionId;
if (typeof target !== "string") {
return { kind: "error", text: "未指定会话。用法: /project archive [sessionId]。" };
}
store.archiveSession(resolved.name, target);
return { kind: "success", text: `会话 ${target} 已归档(永久保留,不受 ${config.retentionDays} 天清理影响)。` };
} catch (error) {
return errorReply(error);
}
}
case "delete": {

@@ -232,7 +442,6 @@ try {

injection.delete(sessionId);
sessionTask.delete(sessionId);
sessionWorktree.delete(sessionId);
}
return {
kind: "success",
text: `项目 ${parsed.name} 已删除(仅删除项目状态,项目文件未动)。`
};
return { kind: "success", text: `项目 ${parsed.name} 已删除(仅删除项目状态,项目文件未动)。` };
} catch (error) {

@@ -257,7 +466,11 @@ return errorReply(error);

const store = new ProjectStore({ home: resolveDshHome(), stateDir: resolved.stateDir });
/** sessionId -> bound project name (explicit binding). */
/** sessionId -> bound project name (explicit binding or NO_PROJECT). */
const bindings = new Map();
/** sessionId -> injection override; absent means default (on). */
const injection = new Map();
const state = { store, bindings, injection, config: resolved };
/** sessionId -> current sub-task name. */
const sessionTask = new Map();
/** sessionId -> current worktree name. */
const sessionWorktree = new Map();
const state = { store, bindings, injection, sessionTask, sessionWorktree, config: resolved };

@@ -275,5 +488,9 @@ ctx.provide("projects", {

injection.delete(sessionId);
sessionTask.delete(sessionId);
sessionWorktree.delete(sessionId);
},
setInjection: (sessionId, enabled) => injection.set(sessionId, enabled),
getInjection: (sessionId) => injection.get(sessionId) ?? true
getInjection: (sessionId) => injection.get(sessionId) ?? true,
setTask: (sessionId, task) => sessionTask.set(sessionId, task),
setWorktree: (sessionId, worktree) => sessionWorktree.set(sessionId, worktree)
});

@@ -283,5 +500,5 @@

name: "project",
description: "isolated projects with persistent background context injected into sessions",
description: "isolated projects with sub-tasks, git worktrees and injected background context",
input: {
hint: "[create <name> [背景] | open <name> | close | list | status | desc [<name>] <背景> | inject on|off | delete <name> | help]"
hint: "[create|open|close|list|status|desc|inject|task|worktree|archive|sessions|delete|help]"
},

@@ -305,3 +522,6 @@ handler: (invocation) => executeProjectCommand(state, invocation)

try {
store.recordSession(name, session.id);
store.recordSession(name, session.id, {
task: sessionTask.get(session.id),
worktree: sessionWorktree.get(session.id)
});
} catch {

@@ -313,3 +533,13 @@ // the project may have been deleted since binding — the

try {
return renderProjectContext(store.get(name));
const project = store.get(name);
const worktreeName = sessionWorktree.get(session.id);
const worktree = worktreeName !== void 0
? (project.worktrees?.find((wt) => wt.name === worktreeName)?.path ?? worktreeName)
: void 0;
return renderProjectContext(project, {
instructionFiles: resolved.instructionFiles,
maxInstructionBytes: resolved.maxInstructionBytes,
task: sessionTask.get(session.id),
worktree
});
} catch {

@@ -316,0 +546,0 @@ return "";

@@ -0,18 +1,88 @@

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Render the per-session project context block that gets injected into the
* system prompt. Returned text is empty only when the project has nothing to
* say (no description) — the marker line still anchors the model to the
* project identity.
* @param project - a project state object (`{name, description, ...}`).
* Render the per-session project context block injected into the system
* prompt. Composed, in order, of:
* 1. project marker
* 2. project instruction files from the project root (Codex AGENTS.md-style)
* 3. background/goal description
* 4. the session's current sub-task (thread), when bound
* 5. the session's current git worktree, when bound
*
* Instruction files are DSH's own `AGENTS.md`/`CLAUDE.md` complement: DSH
* core already auto-loads those two from the workspace, so this plugin's
* defaults are `DEEPSEEK.md` and `AGENT.md` (project-scoped, no double
* injection). The list is configurable.
*/
export const DEFAULT_INSTRUCTION_FILES = ["DEEPSEEK.md", "AGENT.md"];
export const DEFAULT_MAX_INSTRUCTION_BYTES = 16_384;
/**
* Collect and concatenate instruction files from a project root.
* @param root - project root directory.
* @param candidates - ordered file names to try.
* @param maxBytes - total byte budget across all files.
* @returns the concatenated instruction text, or `""` when none exist.
*/
export function collectInstructions(root, candidates, maxBytes = DEFAULT_MAX_INSTRUCTION_BYTES) {
if (typeof root !== "string" || root.length === 0) return "";
const chunks = [];
let total = 0;
for (const file of candidates ?? []) {
if (total >= maxBytes) break;
if (typeof file !== "string" || file.length === 0) continue;
const path = join(root, file);
let text;
try {
text = readFileSync(path, "utf8");
} catch {
continue; // missing/unreadable file — skip
}
const trimmed = text.trim();
if (trimmed.length === 0) continue;
const remaining = maxBytes - total;
const chunk = trimmed.length > remaining ? trimmed.slice(0, remaining) : trimmed;
chunks.push(`# ${file}\n${chunk}`);
total += chunk.length;
}
return chunks.join("\n\n");
}
/**
* Render the full project context block.
* @param project - project state object (`{name, root, description, ...}`).
* @param opts.instructionFiles - ordered instruction file names.
* @param opts.maxInstructionBytes - byte budget for instruction files.
* @param opts.task - the session's current sub-task name, when bound.
* @param opts.worktree - the session's current worktree path, when bound.
* @returns the context block text, or `""` when there is nothing to inject.
*/
export function renderProjectContext(project) {
export function renderProjectContext(project, opts = {}) {
if (project === null || typeof project !== "object") return "";
const name = typeof project.name === "string" ? project.name : "";
if (name.length === 0) return "";
const lines = [`[project: ${name}]`];
const instructions = collectInstructions(
project.root,
opts.instructionFiles ?? DEFAULT_INSTRUCTION_FILES,
opts.maxInstructionBytes ?? DEFAULT_MAX_INSTRUCTION_BYTES
);
if (instructions.length > 0) lines.push(instructions);
const description = typeof project.description === "string" ? project.description.trim() : "";
if (description.length === 0) {
return `[project: ${name}]`;
if (description.length > 0) lines.push(`背景与目标: ${description}`);
if (typeof opts.task === "string" && opts.task.length > 0) {
lines.push(`当前子任务: ${opts.task}`);
}
return `[project: ${name}]\n背景与目标: ${description}`;
if (typeof opts.worktree === "string" && opts.worktree.length > 0) {
lines.push(`当前工作目录(worktree): ${opts.worktree}`);
}
if (lines.length === 1) return `[project: ${name}]`;
return lines.join("\n");
}

@@ -13,2 +13,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";

*
* State v2 adds:
* - `tasks` — named sub-tasks/threads inside one project
* - `worktrees` — isolated git worktrees the project works in
* - `sessions[]` — per-session records with `task`, `worktree`, `archived`
*
* All operations are synchronous: files are tiny and the store is only

@@ -20,3 +25,3 @@ * touched on user commands and per-session first assembly, never on hot

/** Project names: ASCII word-ish, no path separators, no traversal. */
/** Entity names (projects, tasks, worktrees): ASCII word-ish, no path separators. */
export const PROJECT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;

@@ -28,3 +33,4 @@

const INDEX_VERSION = 1;
const STATE_VERSION = 1;
const STATE_VERSION = 2;
const DAY_MS = 86_400_000;

@@ -47,7 +53,11 @@ /** Internal write helper: temp file + atomic rename. */

/** Validate a project name; throws {@link ProjectError} when invalid. */
function nowIso() {
return new Date().toISOString();
}
/** Validate an entity name; throws {@link ProjectError} when invalid. */
export function validateProjectName(name) {
if (typeof name !== "string" || !PROJECT_NAME_RE.test(name)) {
throw new ProjectError(
`invalid project name ${JSON.stringify(name)}: use 1-64 letters, digits, ".", "_" or "-", starting with a letter or digit`
`invalid name ${JSON.stringify(name)}: use 1-64 letters, digits, ".", "_" or "-", starting with a letter or digit`
);

@@ -58,2 +68,24 @@ }

/** Normalize any persisted state shape into the current v2 shape. */
function normalizeState(raw) {
const sessions = (Array.isArray(raw.sessions) ? raw.sessions : []).map((entry) => ({
id: entry.id,
startedAt: entry.startedAt,
task: entry.task,
worktree: entry.worktree,
archived: entry.archived === true
}));
return {
version: STATE_VERSION,
name: raw.name,
root: raw.root,
description: String(raw.description ?? ""),
createdAt: raw.createdAt ?? null,
updatedAt: raw.updatedAt ?? null,
tasks: Array.isArray(raw.tasks) ? raw.tasks : [],
worktrees: Array.isArray(raw.worktrees) ? raw.worktrees : [],
sessions
};
}
export class ProjectStore {

@@ -103,6 +135,5 @@ /**

if (root === null) return null;
const path = this.statePath(root, name);
const raw = readJsonSafe(path, null);
const raw = readJsonSafe(this.statePath(root, name), null);
if (raw === null || raw.name !== name) return null;
return raw;
return normalizeState(raw);
}

@@ -123,2 +154,7 @@

#touch(state) {
state.updatedAt = nowIso();
return state;
}
// ---- public API --------------------------------------------------------

@@ -144,3 +180,3 @@

}
const now = new Date().toISOString();
const now = nowIso();
this.writeState({

@@ -153,2 +189,4 @@ version: STATE_VERSION,

updatedAt: now,
tasks: [],
worktrees: [],
sessions: []

@@ -177,9 +215,12 @@ });

const state = readJsonSafe(this.statePath(meta.root, name), null);
const normalized = state === null ? null : normalizeState(state);
return {
name,
root: meta.root,
description: state?.description ?? "",
createdAt: state?.createdAt ?? null,
updatedAt: state?.updatedAt ?? null,
sessions: state?.sessions ?? []
description: normalized?.description ?? "",
createdAt: normalized?.createdAt ?? null,
updatedAt: normalized?.updatedAt ?? null,
tasks: normalized?.tasks ?? [],
worktrees: normalized?.worktrees ?? [],
sessions: normalized?.sessions ?? []
};

@@ -195,3 +236,3 @@ });

state.description = String(description ?? "");
state.updatedAt = new Date().toISOString();
this.#touch(state);
this.writeState(state);

@@ -201,13 +242,100 @@ return state;

// ---- tasks -------------------------------------------------------------
addTask(name, taskName, description = "") {
validateProjectName(taskName);
const state = this.get(name);
if (state.tasks.some((task) => task.name === taskName)) {
throw new ProjectError(`task ${JSON.stringify(taskName)} already exists in project ${JSON.stringify(name)}`);
}
state.tasks.push({ name: taskName, description: String(description ?? ""), createdAt: nowIso() });
this.#touch(state);
this.writeState(state);
return state;
}
hasTask(name, taskName) {
return this.get(name).tasks.some((task) => task.name === taskName);
}
removeTask(name, taskName) {
const state = this.get(name);
const before = state.tasks.length;
state.tasks = state.tasks.filter((task) => task.name !== taskName);
if (state.tasks.length === before) {
throw new ProjectError(`task ${JSON.stringify(taskName)} not found in project ${JSON.stringify(name)}`);
}
this.#touch(state);
this.writeState(state);
return state;
}
// ---- worktrees ---------------------------------------------------------
addWorktree(name, { name: worktreeName, path, branch }) {
validateProjectName(worktreeName);
const state = this.get(name);
if (state.worktrees.some((wt) => wt.name === worktreeName)) {
throw new ProjectError(`worktree ${JSON.stringify(worktreeName)} already exists in project ${JSON.stringify(name)}`);
}
state.worktrees.push({ name: worktreeName, path, branch: branch ?? null, createdAt: nowIso() });
this.#touch(state);
this.writeState(state);
return state;
}
hasWorktree(name, worktreeName) {
return this.get(name).worktrees.some((wt) => wt.name === worktreeName);
}
removeWorktree(name, worktreeName) {
const state = this.get(name);
const before = state.worktrees.length;
state.worktrees = state.worktrees.filter((wt) => wt.name !== worktreeName);
if (state.worktrees.length === before) {
throw new ProjectError(`worktree ${JSON.stringify(worktreeName)} not found in project ${JSON.stringify(name)}`);
}
this.#touch(state);
this.writeState(state);
return state;
}
// ---- sessions ----------------------------------------------------------
/**
* Record one session into a project's session list (append-only; a
* session id is recorded at most once). This is how a project collects
* its multiple sessions.
* Record one session into a project's session list (upsert). Meta fields
* (`task`, `worktree`, `archived`) are merged onto an existing record; an
* empty diff writes nothing (keeps the hot path allocation-free).
*/
recordSession(name, sessionId) {
recordSession(name, sessionId, meta = {}) {
if (typeof sessionId !== "string" || sessionId.length === 0) return this.get(name);
const state = this.get(name);
if (state.sessions.some((entry) => entry.id === sessionId)) return state;
state.sessions.push({ id: sessionId, startedAt: new Date().toISOString() });
state.updatedAt = new Date().toISOString();
const existing = state.sessions.find((entry) => entry.id === sessionId);
if (existing) {
let changed = false;
if (meta.task !== void 0 && meta.task !== existing.task) {
existing.task = meta.task || void 0;
changed = true;
}
if (meta.worktree !== void 0 && meta.worktree !== existing.worktree) {
existing.worktree = meta.worktree || void 0;
changed = true;
}
if (meta.archived !== void 0 && meta.archived !== existing.archived) {
existing.archived = meta.archived === true;
changed = true;
}
if (!changed) return state;
this.#touch(state);
this.writeState(state);
return state;
}
state.sessions.push({
id: sessionId,
startedAt: nowIso(),
task: meta.task || void 0,
worktree: meta.worktree || void 0,
archived: meta.archived === true
});
this.#touch(state);
this.writeState(state);

@@ -217,2 +345,34 @@ return state;

/** Mark one session record as archived (kept forever, exempt from retention). */
archiveSession(name, sessionId) {
const state = this.get(name);
const entry = state.sessions.find((s) => s.id === sessionId);
if (entry === void 0) {
throw new ProjectError(`session ${JSON.stringify(sessionId)} not in project ${JSON.stringify(name)}`);
}
if (entry.archived) return state;
entry.archived = true;
this.#touch(state);
this.writeState(state);
return state;
}
/**
* Drop non-archived session records older than `retentionDays`. Writes
* only when at least one record is pruned. Call from user-facing reads
* (`list`/`status`/`sessions`), not from the injection hot path.
*/
pruneSessions(name, retentionDays) {
const state = this.get(name);
const cutoff = Date.now() - retentionDays * DAY_MS;
const kept = state.sessions.filter(
(entry) => entry.archived === true || new Date(entry.startedAt).getTime() >= cutoff
);
if (kept.length === state.sessions.length) return state;
state.sessions = kept;
this.#touch(state);
this.writeState(state);
return state;
}
/** The last-opened project name, or null. */

@@ -219,0 +379,0 @@ getDefault() {

{
"name": "dsh-projects",
"version": "0.1.0",
"version": "0.2.0",
"description": "Codex-style projects for DeepSeek Harness: isolated named work units with a persistent background/goal description, injected into every bound session (default on)",

@@ -5,0 +5,0 @@ "type": "module",

+229
-22
# dsh-projects
让 DeepSeek Harness 拥有 **Codex Projects** 式的工作单元:用 `/project` 创建**独立隔离的项目**,每个项目包含**多个会话**、可设定**项目背景/目标说明**,并在每个会话中**默认注入**这段背景(可按会话关闭)。
> Codex-style projects for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): isolated named work units with a persistent background/goal description, injected into every bound session (default on).
灵感来自 OpenAI Codex CLI 的 Projects(`codex project init/open`)。这是面向 DSH 架构的原生实现,非移植。
[![npm version](https://img.shields.io/npm/v/dsh-projects.svg)](https://www.npmjs.com/package/dsh-projects)
[![License](https://img.shields.io/npm/l/dsh-projects.svg)](https://github.com/Alexis-fish/dsh-projects/blob/master/LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/Alexis-fish/dsh-projects.svg)](https://github.com/Alexis-fish/dsh-projects/stargazers)
[![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-0b6bcb)](https://github.com/topics/dsh-plugin)
## 相关链接
**dsh-projects** brings the [Codex Projects](https://github.com/openai/codex) concept to DeepSeek Harness — a native implementation for the DSH architecture, not a port. Every piece of work (a feature, a refactor, a research topic) becomes a **named, isolated project** with its own background/goal description, holding **multiple sessions**, and that description is **injected into every bound session's system prompt by default** (toggleable per session).
- 官方文档:[DeepSeek Harness 开发文档(第一个插件)](https://deepseek-harness.github.io/deepseek-harness/develop/basic/) · [打包与安装插件](https://deepseek-harness.github.io/deepseek-harness/develop/basic/publish)
- 官方源码:[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
- 社区目录:[DSH 创意工坊(dsh-plugin.github.io)](https://dsh-plugin.github.io/)——本站按 GitHub topic `dsh-plugin` 自动收录;本仓库已打该 topic,同步后即可在社区目录检索到。
- 社区组织:[github.com/dsh-plugin](https://github.com/dsh-plugin)
```text
┌─────────────────────────────────────────────────────────────┐
│ You Model │
│ /project create "背景与目标: 重构订单模块..." ← 注入 │
│ alpha 重构订单 (every session, default ON) │
│ │ │
│ ▼ │
│ project.json ── sessions[s1, s2, s3] ── desc ── memos │
└─────────────────────────────────────────────────────────────┘
```
## 安装
---
## Features
- **Isolated projects** — `/project create alpha 重构订单模块` creates an independent work unit; projects never share state.
- **Multiple sessions per project** — new sessions auto-inherit the last-opened project (`autoBindDefault`), and every bound session is recorded into the project's session list.
- **Sub-tasks / threads** — `/project task create|switch|list|close` groups sessions into named sub-tasks inside one project.
- **git worktree isolation** — `/project worktree add|switch|list|remove` creates isolated git worktrees (`<root>-<name>`) and binds sessions to them; the worktree path is injected so the model works in isolation.
- **Auto-loaded instruction files** — `DEEPSEEK.md` / `AGENT.md` from the project root are injected on every prompt, Codex-`AGENTS.md`-style (DSH core already loads `AGENTS.md`/`CLAUDE.md`, so this plugin complements rather than duplicates).
- **Persistent background/goal description** — set once with `/project desc`, injected forever after.
- **Session history + retention** — every session is recorded; `/project archive` keeps one forever, non-archived records older than 7 days are auto-pruned.
- **Per-session injection toggle, default ON** — `/project inject off` silences the background for the current session only; every other session keeps it.
- **Zero runtime dependencies** — pure Cordis (`commands` + `systemPrompt` services), plain ESM JavaScript, no build step required.
- **Portable state** — project state lives inside the project directory (`<root>/.dsh/projects/`), committable to git, discoverable from anywhere via `$DSH_HOME/projects.json`.
## Install
> Requires the `dsh` CLI and pnpm on your PATH.
```sh
# 方式一:从 GitHub 安装(无需 npm,推荐;本包为纯 JS 无构建步骤,
# 不需要 pnpm 的 allowBuilds 授权)
# npm (published as dsh-projects@0.1.0)
dsh plugin --profile web add dsh-projects
# or directly from GitHub (no npm needed; plain JS, no build permission required)
dsh plugin --profile web add github:Alexis-fish/dsh-projects
# 方式二:锁定版本标签安装(更稳妥,可复现)
# or pinned to a release tag
dsh plugin --profile web add github:Alexis-fish/dsh-projects#v0.1.0
# 方式三:npm 发布后(尚未发布时忽略此方式)
# or from a local checkout
dsh plugin --profile web add file:/path/to/this/repo
```
`dsh plugin` automatically appends `dsh-projects` to `dsh.profile.bundles`. Restart your profile to activate.
## Quick start
```text
/project create alpha 重构订单模块,迁移到 vitest
→ 项目已创建并绑定当前会话: alpha
(工作若干会话后,第二天新开会话 —— 自动继承 alpha 项目)
/project status
→ 项目: alpha
根目录: C:/dev/app
背景与目标: 重构订单模块,迁移到 vitest
会话数: 4(最近: 2026-08-14T09:12:00Z)
本会话注入: 开
/project desc alpha 迁移完成,下一步:接入新支付网关
→ 项目 alpha 的背景与目标已更新
/project inject off # 只关掉当前会话的注入
/project close # 当前会话脱离项目(数据保留)
```
## Command reference
| Command | Description |
|---|---|
| `/project create <name> [background]` | Create a project and bind the current session |
| `/project open <name>` | Bind the current session to an existing project |
| `/project close` | Unbind the current session (stays unbound even with a default project) |
| `/project list` | List all projects (most recently active first) |
| `/project status` | Current session's project, background, sessions and injection state |
| `/project desc [<name>] <background>` | Set the project's background/goal description |
| `/project inject on\|off` | Toggle background injection for the **current session** (default on) |
| `/project task create <name> [说明]` | Create a sub-task/thread in the project |
| `/project task list` | List sub-tasks |
| `/project task switch <name>` | Bind the current session to a sub-task |
| `/project task close` | Unbind the current session's sub-task |
| `/project worktree add <name> [branch]` | Create an isolated git worktree |
| `/project worktree list` | List worktrees |
| `/project worktree switch <name>` | Bind the current session to a worktree |
| `/project worktree remove <name>` | Remove a git worktree |
| `/project archive [sessionId]` | Archive a session (kept forever) |
| `/project sessions` | List project sessions with retention status |
| `/project delete <name>` | Remove project state (**never touches project files**) |
| `/project help` | Usage |
## How it works
Bound sessions get the project context injected as a dynamic system-prompt section (`systemPrompt.context`, order `115`) on every prompt assembly:
```text
[project: alpha]
# DEEPSEEK.md
<project root instruction file contents>
背景与目标: 重构订单模块,迁移到 vitest
当前子任务: auth-refactor
当前工作目录(worktree): C:/dev/app-auth-refactor
```
The model therefore always knows the project's background, goals, current thread and isolated work directory. `/project inject off` returns `""` for that session only.
### Storage layout
```
<project root>/.dsh/projects/<name>.json # per-project state (source of truth, git-committable)
$DSH_HOME/projects.json # discovery index: name -> root + default project
```
```jsonc
{
"version": 1,
"name": "alpha",
"root": "C:/dev/app",
"description": "重构订单模块,迁移到 vitest",
"createdAt": "2026-08-13T09:00:00.000Z",
"updatedAt": "2026-08-14T09:12:00.000Z",
"sessions": [{ "id": "sess-1", "startedAt": "2026-08-13T09:05:00.000Z" }]
}
```
## Configuration
The plugin row id is `projects`. Defaults:
| Key | Default | Description |
|---|---|---|
| `stateDir` | `.dsh` | Directory name inside a project root holding per-project state |
| `injectOrder` | `115` | Order of the injected context among system-prompt contexts |
| `autoBindDefault` | `true` | New sessions without an explicit binding inherit the default project |
| `instructionFiles` | `["DEEPSEEK.md", "AGENT.md"]` | Instruction files auto-loaded from the project root |
| `maxInstructionBytes` | `16384` | Total byte budget across instruction files |
| `retentionDays` | `7` | Non-archived session records older than this are pruned |
Override from your profile's `cordis.patch.yml` by row id (**`config` is replaced wholesale — restate every key**):
```yaml
- id: projects
config:
stateDir: '.dsh'
injectOrder: 130
autoBindDefault: false
instructionFiles: ['DEEPSEEK.md', 'AGENT.md', 'AGENTS.md']
maxInstructionBytes: 32768
retentionDays: 14
```
## Development
```sh
npm test # node --test — 29 tests incl. a real @deepseek-ai/cordis Context mount
```
The plugin is plain ESM JavaScript with **zero runtime dependencies**; the repo also ships `DESIGN.md` with the full vision (v0.2+ roadmap: agent tools, session summaries, UI panel, git awareness).
## FAQ
- **Why not "project-management"?** The plugin organizes project *context* (background, sessions, injection) — it does not track tasks, milestones or progress. `projects` matches the Codex terminology this is inspired by.
- **How is this different from `dsh-memory` / `dsh-track`?** Memory plugins persist *facts* across sessions; task plugins manage *tasks*. dsh-projects scopes *work units*: one named project, many sessions, one shared background injected by default.
- **Is my data safe on `/project delete`?** Yes — deletion removes only the project state file and index entry; project files are never touched.
- **Does it change default behavior?** No. Without a bound project, sessions behave exactly as before.
## Links
- Official docs: [First plugin](https://deepseek-harness.github.io/deepseek-harness/develop/basic/) · [Packaging & installing plugins](https://deepseek-harness.github.io/deepseek-harness/develop/basic/publish)
- Official source: [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
- Community directory: [dsh-plugin.github.io (DSH 创意工坊)](https://dsh-plugin.github.io/) — auto-collected from the `dsh-plugin` GitHub topic
- Community org: [github.com/dsh-plugin](https://github.com/dsh-plugin)
## License
[MIT](./LICENSE) © 2026 Alexis-fish
---
# 中文说明
**dsh-projects** 让 DeepSeek Harness 拥有 **Codex Projects** 式的工作单元:用 `/project` 创建**独立隔离的项目**,每个项目包含**多个会话**、可设定**项目背景/目标说明**,并在每个会话中**默认注入**这段背景(可按会话关闭)。灵感来自 OpenAI Codex CLI 的 Projects(`codex project init/open`),这是面向 DSH 架构的原生实现,非移植。
## 安装
```sh
# 方式一:npm 官方 registry(已发布 dsh-projects@0.1.0,推荐)
dsh plugin --profile web add dsh-projects
# 方式二:从 GitHub 安装(无需 npm;纯 JS 无构建步骤,不需要 allowBuilds 授权)
dsh plugin --profile web add github:Alexis-fish/dsh-projects
# 方式三:锁定版本标签安装(可复现)
dsh plugin --profile web add github:Alexis-fish/dsh-projects#v0.1.0
# 方式四:本地开发/未发布时(相对路径会自动锚定到当前目录)

@@ -44,2 +226,12 @@ dsh plugin --profile web add file:<本仓库绝对路径>

| `/project inject on\|off` | 本会话是否注入项目背景(**默认开**) |
| `/project task create <name> [说明]` | 在项目内创建子任务/线程 |
| `/project task list` | 列出项目子任务 |
| `/project task switch <name>` | 绑定当前会话到子任务 |
| `/project task close` | 解除当前会话的子任务绑定 |
| `/project worktree add <name> [branch]` | 创建隔离的 git worktree |
| `/project worktree list` | 列出项目 worktree |
| `/project worktree switch <name>` | 绑定当前会话到 worktree |
| `/project worktree remove <name>` | 移除 git worktree |
| `/project archive [sessionId]` | 归档会话(永久保留) |
| `/project sessions` | 列出项目会话与保留状态 |
| `/project delete <name>` | 删除项目状态(**不删除项目文件**) |

@@ -54,3 +246,3 @@ | `/project help` | 用法 |

```
```text
[project: alpha]

@@ -69,3 +261,3 @@ 背景与目标: 重构订单模块,迁移到 vitest

```
```jsonc
{

@@ -91,2 +283,5 @@ "version": 1,

| `autoBindDefault` | `true` | 新会话是否自动继承默认项目 |
| `instructionFiles` | `["DEEPSEEK.md", "AGENT.md"]` | 从项目根目录自动加载的指令文件 |
| `maxInstructionBytes` | `16384` | 指令文件总字节预算 |
| `retentionDays` | `7` | 未归档会话超过该天数自动清理 |

@@ -101,18 +296,30 @@ 在 profile 的 `cordis.patch.yml` 中按 id 覆盖(**注意:`config` 是整体替换**,覆盖时需写全所有键):

autoBindDefault: false
instructionFiles: ['DEEPSEEK.md', 'AGENT.md', 'AGENTS.md']
maxInstructionBytes: 32768
retentionDays: 14
```
## 开发
## 开发与路线图
```sh
npm test # node --test,含真实 cordis Context 挂载测试
npm test # node --test,46 项测试(含真实 cordis Context 挂载测试、真实 git worktree 测试)
```
零运行时依赖:插件只使用 Cordis 注入的 `commands` 与 `systemPrompt` 服务。
## 路线图(见 DESIGN.md)
- [x] v0.1:`/project` 全套命令 + 项目存储 + 会话背景注入(默认开、按会话关)
- [ ] v0.2:agent 工具(`project_status` / `project_memo_add`,模型主动记忆)
- [x] v0.2:子任务(task)+ git worktree 隔离 + 指令文件自动加载(DEEPSEEK.md/AGENT.md)+ 会话归档与 7 天保留清理
- [ ] v0.3:会话结束自动摘要(`lastSummary`,续接锚点)+ 定时项目日报
- [ ] v0.4:Web 侧边栏项目面板
- [ ] v0.4:Web 侧边栏项目仪表盘(可视化/切换)
- [ ] v0.5:git 感知(记录 branch/commit)与项目模板
完整设计见 [`DESIGN.md`](./dsh-projects/DESIGN.md)。
## 相关链接
- 官方文档:[第一个插件](https://deepseek-harness.github.io/deepseek-harness/develop/basic/) · [打包与安装插件](https://deepseek-harness.github.io/deepseek-harness/develop/basic/publish)
- 官方源码:[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
- 社区目录:[DSH 创意工坊(dsh-plugin.github.io)](https://dsh-plugin.github.io/)——本站按 GitHub topic `dsh-plugin` 自动收录
- 社区组织:[github.com/dsh-plugin](https://github.com/dsh-plugin)
## License
[MIT](./LICENSE) © 2026 Alexis-fish