dsh-projects
Advanced tools
| 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 |
+71
-17
@@ -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]; |
+280
-50
| 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 ""; |
+79
-9
@@ -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"); | ||
| } |
+180
-20
@@ -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() { |
+1
-1
| { | ||
| "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 架构的原生实现,非移植。 | ||
| [](https://www.npmjs.com/package/dsh-projects) | ||
| [](https://github.com/Alexis-fish/dsh-projects/blob/master/LICENSE) | ||
| [](https://github.com/Alexis-fish/dsh-projects/stargazers) | ||
| [](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 |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
64506
112.93%10
11.11%1145
86.18%321
181.58%4
100%1
Infinity%