dsh-plugin-worktrees 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/TASKS.md ADDED
@@ -0,0 +1,164 @@
1
+ # dsh-worktrees — 实施任务分解
2
+
3
+ > 配套 `docs/DESIGN.md` 阅读(D 表示其章节,R 表示其 §12 红线)。任务可独立派发给实现代理;每个任务自包含:目标 / 范围与涉及文件 / 验收标准 / 依赖。
4
+ >
5
+ > 通用约束(每个任务默认遵守):
6
+ > - **唯一 git 出口**:一切 git 经 `lib/git-port.js` argv 数组(R1);`lib/` 其它模块出现 `spawn(` 即 lint 失败(T12)。
7
+ > - 测试一律 `node:test`;真 git fixture 全本地 `git init`(tmp 目录),**零网络零远端**(R8);迁移自 task-weaver 的用例不得削弱断言。
8
+ > - 迁移代码(git-port / merge-queue / worktree-service / naming)保持行级等价,仅做 DESIGN 明确要求的改名/收窄/替换;原注释语义保留。
9
+ > - Plain JS ESM;零新增运行时依赖(zod 除外,同家族版本);`engines >=18`(不用 `Promise.withResolvers`)。
10
+
11
+ ## 阶段总览与依赖图
12
+
13
+ ```
14
+ P0 脚手架 T01
15
+ P1 基础引擎 T02 T03 T04 T05 (T02 先行;T03/T04/T05 依赖 T02,互相独立可并行)
16
+ P2 工作区服务 T06 T07 (T06 依赖 T02-T05;T07 依赖 T06)
17
+ P3 集成队列 T08 T09 (T08 依赖 T06;T09 依赖 T08)
18
+ P4 工具面 T10 T11 T12 (T10 依赖 T07+T08;T11/T12 依赖 T10)
19
+ P5 总装与质量 T13 T14 T15 (T13 依赖全部;T14/T15 收尾)
20
+ ```
21
+
22
+ ---
23
+
24
+ ## P0 脚手架
25
+
26
+ ### T01 仓库脚手架与包清单
27
+ - **目标**:新仓库可 `npm install && npm test`(空测试)通过,包身份与依赖契约定稿。
28
+ - **范围/文件**:`package.json`(name `dsh-worktrees`;`type: module`;`engines: {node: ">=18"}`;`dsh.bundle.patch: "./cordis.patch.yml"`;deps `zod@^3.23.0`;peerDependencies `@deepseek-ai/{cordis@^4.0.1, dsh-tools@^0.1.0-rc.6}`;scripts test/lint/setup:peer)、`.gitignore`、`LICENSE`(MIT)、`test/.smoke.test.js`、`scripts/link-harness-dsh-tools.sh`(照搬 dsh-plugin-subagents 同名脚本模式:动态解析 live harness 根,禁止硬编码)。
29
+ - **验收**:`npm test` 绿;`dsh.bundle.patch` 字段存在;空 `cordis.patch.yml` 可被 YAML 解析。
30
+ - **依赖**:无。
31
+
32
+ ---
33
+
34
+ ## P1 基础引擎(一切的地基)
35
+
36
+ ### T02 GitPort 直搬(最大单体,先行)
37
+ - **目标**:`lib/git-port.js` 就位 —— task-weaver `git-port.ts` 逐行直搬 + DESIGN §4.1 指定改写。
38
+ - **范围/文件**:`lib/git-port.js`、`test/git-port.test.js`。
39
+ - 直搬:`runGit`(argv spawn + shell:false + 15s 超时 SIGKILL + 双流独立 8MiB 记账上限 + `[output capped]` 标记)、`parseGitStatusPorcelain`(XY 码 / rename target / y=R / 引号剥离 / `?`→added)、`parseGitNameStatus`(R/C 双 tab / 未知字母→unknown)、`NodeGitPort`(原 `BunGitPort`):`isGitRepo` / `resolveHead` / `resolveRef`(**`--verify --end-of-options <ref>^{commit}`** 防注入,逐字符照抄)/ `createWorktree` / `removeWorktree`(--force 幂等)/ `commitAll`(add -A → commit → HEAD + name-status)/ `status` / `checkout` / `cherryPick` / `mergeBase` / `listConflicts`(`--diff-filter=U`,非零→空清单)/ `addWorktreeAt` / `ensureBranch`;构造器 `GIT_TERMINAL_PROMPT: "0"`。
40
+ - 改写:类名 `BunGitPort`→`NodeGitPort`;裁私有 `worktreePath()`(mkdtemp 布局);导出 `createGitPort({ spawnImpl })` 注入缝(默认 runGit)。
41
+ - 新增:`listWorktrees`(`worktree list --porcelain` + 新解析函数 `parseWorktreeListPorcelain`:空行分块,worktree/HEAD/branch/detached 字段)、`isAncestor`(`merge-base --is-ancestor` exit 0/1)、`validateBranch`(`check-ref-format --branch`)、`mergeNoFf`(`["merge","--no-ff","--no-edit","-m",msg,"--",branch]`)、`abortMerge`。
42
+ - 测试:bun:test→node:test 直迁 `git-port.test.ts`(fixture:argv-only git 助手独立于被测 runGit、显式 GIT_AUTHOR_*/COMMITTER_* env、独立 worktree 父目录)+ 新方法用例(porcelain 解析表、isAncestor 两态、validateBranch 拒 `..`/空格/前导 `-`)。
43
+ - **验收**:全部用例绿(本地 git 2.x);grep 确认无 `shell: true`、无字符串拼接 argv;非零 git 退出不 reject(status 字段语义保留)。
44
+ - **依赖**:T01。
45
+
46
+ ### T03 naming(分支/路径命名,纯函数)
47
+ - **目标**:`lib/naming.js` —— DESIGN §5.2.1。
48
+ - **范围/文件**:`lib/naming.js`、`test/naming.test.js`:`sanitizeBranch`(`[^a-zA-Z0-9_.-]`→`_`,截 48)、`taskBranch(sessionShort, task)` → `dsh-wt/<session>/<task>`、`integrationBranch(sessionShort)`、`worktreePath(worktreeRoot, repoKey, sessionShort, taskSlug)`(join 拼装)、`integrationWorktreePath(worktreeRoot, repoKey, branchSanitized, jobId)`、`repoIdFromRoot`(FNV-1a 8-hex,**直搬 workspace-service.ts L682-690**:反斜杠规范化 + 尾斜杠剥离 + `0x811c9dc5`/`0x01000193`)。
49
+ - **验收**:纯函数表驱动全绿;`repoIdFromRoot` 与源行为逐例一致(同路径同 id、Windows 反斜杠规范化、空路径→`_` 兜底语义照源 sanitize);一切路径产物 `path.isAbsolute`。
50
+ - **依赖**:T02(无代码依赖,仅需 GitPort 已定型避免返工)。
51
+
52
+ ### T04 StateStore(JSON 原子写持久化)
53
+ - **目标**:`lib/state-store.js` —— DESIGN §7.1。
54
+ - **范围/文件**:`lib/state-store.js`、`test/state-store.test.js`:`createStateStore({ path })` → `{ load(), persist(), get worktrees/jobs/repos, upsertWorktree, upsertJob, pruneTerminalJobs(keep=200), findActiveJob(repoKey, branch), findQueuedJobs(repoKey, branch), nextOrderIndex(repoKey, branch) }`。写纪律:深拷贝→stringify(,2)→tmp(`wx` + 0600 + best-effort chmod)→rename;`__proto__`/`constructor`/`prototype` 键防护;启动载入时终态 job 修剪。
55
+ - **验收**:写后重载 round-trip 一致;0600 断言;proto 键注入被丢;修剪保留最近 N 条终态;并发 upsert(async 交错)无丢失(内存索引即真相)。
56
+ - **依赖**:T02。
57
+
58
+ ### T05 repo-gate(canonical 门禁)
59
+ - **目标**:`lib/repo-gate.js` —— DESIGN §5.2.0。
60
+ - **范围/文件**:`lib/repo-gate.js`、`test/repo-gate.test.js`:`resolveRepoRoot({ repoArg, sessionCwd, workspacePaths, allowedRoots, git })`:缺省→sessionCwd→仍缺 throw `repo_unresolved`;`realpath` 失败 throw `repo_unknown`;非 git throw `not_a_git_repo`;门禁按序(session-cwd 子树 → workspace 列表 → allowedRoots)任一 `isPathInside`(规范化 + Windows 大小写不敏感,**照搬 aionui-panel gate.ts L30-45**)通过,全拒 throw `repo_not_registered`(报 canonical + 指引)。无 any-root 开关。
61
+ - **验收**:符号链接 repo(realpath 后命中 workspace)通过;`..` 逃逸、未注册绝对路径、allowedRoots 外路径均拒;sessionCwd 缺席 + 无 workspaceRegistry 时的降级路径正确。
62
+ - **依赖**:T02。
63
+
64
+ ---
65
+
66
+ ## P2 工作区服务
67
+
68
+ ### T06 WorktreeService(workspace-service 收窄版)
69
+ - **目标**:worktree 模式 CRUD —— DESIGN §5.2.1/§5.3/§5.4/§5.5/§5.8 引擎侧。
70
+ - **范围/文件**:`lib/worktree-service.js`、`test/worktree-service.test.js`。
71
+ - `create({ task, repoRoot, baseRef, integrationBranch, note, sessionId, cfg })`:`validateBranch(sanitizeBranch)` → 重名 `branch_exists` → `maxWorktrees` 超限 `max_worktrees_reached` → `resolveRef(baseRef ?? cfg.defaultBaseRef)`(**禁 mutable-HEAD 回退:base 必须落成具体 oid**,语义照搬 workspace-service.ts L268-284)→ 父目录预建 + `createWorktree` → 记录(active)。
72
+ - `list({ repoRoot? })`:对照 `gitPort.listWorktrees` 标 `orphaned/vanished`;`status(idOrPath)`:head/dirty/changes(porcelain 映射)/ahead。
73
+ - `cleanup({ idOrPath, force, acknowledge, keepBranch })`:**保护判定** —— 记录非 merged 且 `!isAncestor(repoRoot, sourceHead, integrationHead)` 且 `!(force && acknowledge)` → throw `cleanup_protected`(消息含 sourceHead/integrationHead/两参数用法);通过 → `removeWorktree`(幂等)+ `branch -D`(keepBranch false 时;分支删除用 `["branch","-D","--",branch]`);vanished 记录只清状态。
74
+ - **验收**:真 git fixture 全链(create→写文件→cleanup 保护拒绝→force+acknowledge 放行);`branch_exists` 幂等语义; vanished 清记录不碰 git 成功。
75
+ - **依赖**:T02 T03 T04 T05。
76
+
77
+ ### T07 查询工具三件(list/status/create)
78
+ - **目标**:`lib/tools/worktree-create.js` / `worktree-list.js` / `worktree-status.js` —— DESIGN §5.3-5.5 的 schema 与返回形状。
79
+ - **范围/文件**:三工具模块 + `test/tools.test.js`(fake ctx:`tools.register` 收集器 + 真 git fixture):参数 schema 逐字对齐 DESIGN §5.3-5.5;返回对象**条件展开构造**(无 undefined 值键 —— json-safe E3 纪律);create 的 `delegate_hint` 含 subagent cwd 组合提示文案;错误码族齐备(`repo_*` / `invalid_*` / `branch_exists` / `max_worktrees_reached`)。
80
+ - **验收**:fake ctx 注册断言(name/description 含组合示例);create 返回 path 即 GitPort 产物(fixture 验证真 worktree 存在);status 的 changes 映射(added/modified/renamed…)用 porcelain 构造样例断言。
81
+ - **依赖**:T06。
82
+
83
+ ---
84
+
85
+ ## P3 集成队列
86
+
87
+ ### T08 MergeQueue 引擎(骨架 + collect)
88
+ - **目标**:`lib/merge-queue.js` —— DESIGN §6。
89
+ - **范围/文件**:`lib/merge-queue.js`、`test/merge-queue.test.js`。
90
+ - `collect(worktree)`:status →(autoCollect 且脏)`commitAll` → `resolveHead`;净树且 `mergeBase(head, base) === head` → `{ state:'no_changes' }`(**change-collector.ts L162-222 语义直搬**,no_changes 在 DSH 降级为正常结局)。
91
+ - `enqueue(params)`(origin/correlationId 字段 day-one);`drain(repoKey, branch)`:promise 链(`Map<key, Promise>` 尾接续,finally 自清)→ `applyUntilBlockedOrEmpty`。
92
+ - `applyOne` 十步(DESIGN §6.3 对照表逐步):**步骤 5 先写 job.integrationWorktree/integrationHeadBefore 再跑 git**(崩溃证据写序);步骤 7 `mergeNoFf`;步骤 8 冲突分类(非零退出 → listConflicts → 非空或 /conflict/i → conflicted 记 conflictFiles **保留 worktree**;否则 `abortMerge` best-effort → failed);步骤 10 finally 非 conflicted removeWorktree(best-effort)。
93
+ - 命令 `cancel/retry/resolve`(**merge-queue.ts L266-400 逐条直搬语义**:幂等 + invalid_state 矩阵 + 分支释放触发 drain);resolve/retry 不删 retained worktree(源 L309-311 注释语义,记 `abandonedIntegrationWorktrees`)。
94
+ - `MergeError` 形状映射 DESIGN §5.6(active_job_exists 是返回态非异常)。
95
+ - **验收**:迁移用例全绿 —— enqueue 单调序(merge-queue.test L358-399)、clean merge(merge --no-ff 版:集成 HEAD 是 merge commit 且源提交全在)、conflict(conflictFiles + worktree 保留断言)、one-active + 冲突后阻塞(drain 版)、cancel/retry/resolve 幂等与 invalid_state 矩阵(commands.test L166-382 语义)、collect 脏树/净树两态;新增:两分支并行 drain 不互阻、同分支严格串行、多提交源分支无损集成。
96
+ - **依赖**:T06(用其记录形状与 GitPort)。
97
+
98
+ ### T09 写工具三件(merge/queue/cleanup)
99
+ - **目标**:`lib/tools/worktree-merge.js` / `worktree-queue.js` / `worktree-cleanup.js` —— DESIGN §5.6-5.8。
100
+ - **范围/文件**:三工具模块 + tools.test 扩展:merge 的 `isConcurrencySafe: () => false`;返回 oneOf 五态(succeeded/conflicted(含 resolution_hint)/queued(queued_ahead)/no_changes/failed)形状逐字对齐;queue 的四 action(list 含 branch_holders);cleanup 双布尔保护 + keep_branch。
101
+ - **验收**:真 git fixture 编排序列(DESIGN §5.3 组合示例 9 步全走通);conflicted 返回的 integration_worktree 在 fixture 上真实存在且含冲突标记;`active_job_exists` 映射为 failed 返回态(非 throw)断言;cleanup 单 force 拒绝 / 双布尔放行。
102
+ - **依赖**:T08。
103
+
104
+ ---
105
+
106
+ ## P4 总装
107
+
108
+ ### T10 apply() 总装
109
+ - **目标**:`lib/index.js` + `lib/config.js` —— DESIGN §8.1/§8.2。
110
+ - **范围/文件**:`lib/config.js`(zod strict §8.1 全表 + 未知键 fail loud);`lib/index.js`:`export const inject = ['tools']`(workspaceRegistry 可选探测不进 inject)→ validateConfig → `assertSingleDshToolsInstance`(Symbol 自检,照搬 subagents lib/index.js L79-98)→ StateStore 载入 → **reconcile(先于工具注册**,DESIGN §7.3:applying→failed、vanished 标记、孤儿 integration 清理 best-effort、conflicted 原样保留、repo 不可达全标 orphaned)→ 按 register 开关注册六工具 → 返回 undefined。`lib/repo-gate.js` 的 workspacePaths 来源:`ctx.workspaceRegistry?.list()?.map(w => w.path)`(缺席→空数组降级)。
111
+ - **验收**:`test/index.test.js`:装配后 fake ctx 有 6 工具;reconcile 对预置 state.json(含 applying job + 孤儿 worktree)的对账断言;config strict 拒未知键;apply 返回值严格 undefined。
112
+ - **依赖**:T07 T09。
113
+
114
+ ### T11 bundle patch + README
115
+ - **目标**:`cordis.patch.yml` 单 insert 行(DESIGN §8.3)+ 双语 README。
116
+ - **范围/文件**:`cordis.patch.yml`(`- insert:` → `- id: worktrees` + `name: dsh-worktrees`,config 注释示例);`README.md`/`README.zh.md`(中英同步:定位、安装(`dsh plugin --profile web add` + `npm run setup:peer`)、与 dsh-plugin-subagents 组合的编排示例(DESIGN §5.3 九步)、配置表、多 dsh 实例并发风险说明(O5)、非目标清单);`CHANGELOG.md` 0.1.0;`AGENTS.md`(红线继承 DESIGN §12 十条);`SECURITY.md`。
117
+ - **验收**:YAML 可解析、行 id/name 形态与 dsh-ssh 先例一致;README 两语言段落对齐;组合示例与工具真实返回字段一致。
118
+ - **依赖**:T10。
119
+
120
+ ### T12 lint + CI
121
+ - **目标**:纪律机械化。
122
+ - **范围/文件**:`scripts/lint.js`(`node --check` 全模块 + **git 出口纪律:`lib/` 内 `spawn(` 仅允许出现在 `git-port.js`**,正/反例内嵌测试);`.github/workflows/ci.yml`(macOS/Ubuntu/Windows × Node 18/20/22,`npm ci && npm run lint && npm test`);`npm pack --dry-run` 核对 files(lib/ cordis.patch.yml docs? README* CHANGELOG LICENSE)。
123
+ - **验收**:CI 三平台绿(fixture 全本地 git,裸 runner 可过);lint 对越界 spawn 样例报错;dry-run 内容物只含预期。
124
+ - **依赖**:T10。
125
+
126
+ ---
127
+
128
+ ## 任务依赖速查
129
+
130
+ | 任务 | 依赖 |
131
+ |---|---|
132
+ | T01 | — |
133
+ | T02 | T01 |
134
+ | T03 T04 T05 | T02 |
135
+ | T06 | T02 T03 T04 T05 |
136
+ | T07 | T06 |
137
+ | T08 | T06 |
138
+ | T09 | T08 |
139
+ | T10 | T07 T09 |
140
+ | T11 | T10 |
141
+ | T12 | T10 |
142
+
143
+ ## 建议派发批次
144
+
145
+ 1. **批 1**:T01 → T02(GitPort 是一切地基,先行单独派)。
146
+ 2. **批 2**:T03 / T04 / T05 并行(纯逻辑互不依赖)。
147
+ 3. **批 3**:T06 →(T07 / T08 并行)。
148
+ 4. **批 4**:T09 → T10。
149
+ 5. **批 5**:T11 / T12 并行收尾。
150
+
151
+ ## 工程量预算(对照 DESIGN §13)
152
+
153
+ | 任务 | 预估 |
154
+ |---|---|
155
+ | T01 | 0.25 天 |
156
+ | T02 | 1 天(直搬 585 行 + 迁测 593 行 + 5 新方法) |
157
+ | T03+T04+T05 | 各 0.5 天(可并行) |
158
+ | T06 | 1 天 |
159
+ | T07 | 0.5 天 |
160
+ | T08 | 1.5 天(核心:十步 + 命令 + 冲突保留断言) |
161
+ | T09 | 1 天(编排序列端到端) |
162
+ | T10 | 0.75 天(reconcile 是隐藏工作量) |
163
+ | T11+T12 | 0.5 天 |
164
+ | **合计** | **~7.5 人日**(单人);并行派发(批 2/3 双代理)可压缩至 ~5 个日历日 |
package/lib/config.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Configuration validation (T10, DESIGN §8.1).
3
+ *
4
+ * zod strict schema, field-for-field the §8.1 table. Invalid config —
5
+ * including unknown/misspelled keys — fails LOUDLY at apply time with a
6
+ * precise message instead of surfacing as a confusing runtime error later
7
+ * (style follows dsh-plugin-subagents lib/config.js).
8
+ *
9
+ * validateConfig(rawConfig) returns the RESOLVED final config: every
10
+ * optional key carries its §8.1 default, `register` is normalised into the
11
+ * full six-boolean form, and the two path defaults are materialised from
12
+ * os.homedir():
13
+ * - worktreeRoot → ~/.dsh/worktrees/
14
+ * - statePath → ~/.dsh/dsh-worktrees/state.json
15
+ *
16
+ * Engines receive this resolved object verbatim (createWorktreeService /
17
+ * createMergeQueue apply only defensive numeric guards on top).
18
+ */
19
+
20
+ import { z } from 'zod'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+
24
+ /** Default worktree root: `~/.dsh/worktrees/` (DESIGN §8.1). */
25
+ export function defaultWorktreeRoot() {
26
+ return path.join(os.homedir(), '.dsh', 'worktrees')
27
+ }
28
+
29
+ /** Default state file: `~/.dsh/dsh-worktrees/state.json` (DESIGN §7.1/§8.1). */
30
+ export function defaultStatePath() {
31
+ return path.join(os.homedir(), '.dsh', 'dsh-worktrees', 'state.json')
32
+ }
33
+
34
+ /**
35
+ * Tool-family switches (§8.1 `register`) — all optional booleans, strict
36
+ * (an unknown switch name is a config typo and fails loud).
37
+ */
38
+ export const registerSchema = z.object({
39
+ create: z.boolean().optional(),
40
+ list: z.boolean().optional(),
41
+ status: z.boolean().optional(),
42
+ merge: z.boolean().optional(),
43
+ queue: z.boolean().optional(),
44
+ cleanup: z.boolean().optional(),
45
+ }).strict()
46
+
47
+ /**
48
+ * The full §8.1 plugin config table. `.strict()` on every object level so
49
+ * unknown keys (typos like `worktreeRoots` or `maxWorktree`) reject at
50
+ * apply time instead of being silently ignored.
51
+ */
52
+ export const pluginConfigSchema = z.object({
53
+ register: registerSchema.optional(),
54
+ worktreeRoot: z.string().min(1).optional(),
55
+ statePath: z.string().min(1).optional(),
56
+ maxWorktrees: z.number().int().positive().optional(),
57
+ defaultBaseRef: z.string().min(1).optional(),
58
+ gitTimeoutMs: z.number().int().positive().optional(),
59
+ mergeTimeoutMs: z.number().int().positive().optional(),
60
+ allowedRoots: z.array(z.string().min(1)).optional(),
61
+ requireWorkspaceRegistration: z.boolean().optional(),
62
+ autoCollect: z.boolean().optional(),
63
+ retainJobHistory: z.number().int().positive().optional(),
64
+ }).strict()
65
+
66
+ /** register defaults: the whole tool family is on unless switched off. */
67
+ export const REGISTER_DEFAULTS = Object.freeze({
68
+ create: true,
69
+ list: true,
70
+ status: true,
71
+ merge: true,
72
+ queue: true,
73
+ cleanup: true,
74
+ })
75
+
76
+ /** Scalar defaults (§8.1 comment column). */
77
+ export const SCALAR_DEFAULTS = Object.freeze({
78
+ maxWorktrees: 16,
79
+ defaultBaseRef: 'HEAD',
80
+ gitTimeoutMs: 15_000,
81
+ mergeTimeoutMs: 120_000,
82
+ requireWorkspaceRegistration: true,
83
+ autoCollect: true,
84
+ retainJobHistory: 200,
85
+ })
86
+
87
+ /**
88
+ * Validate + resolve the raw plugin config (DESIGN §8.1).
89
+ *
90
+ * @param {object} [config] raw host config
91
+ * @returns {{
92
+ * register: {create: boolean, list: boolean, status: boolean, merge: boolean, queue: boolean, cleanup: boolean},
93
+ * worktreeRoot: string, statePath: string,
94
+ * maxWorktrees: number, defaultBaseRef: string,
95
+ * gitTimeoutMs: number, mergeTimeoutMs: number,
96
+ * allowedRoots: string[], requireWorkspaceRegistration: boolean,
97
+ * autoCollect: boolean, retainJobHistory: number,
98
+ * }} the final config with every default applied
99
+ * @throws {Error} `dsh-worktrees: invalid config — <issues>` on any unknown
100
+ * key or out-of-contract value (zod strict behaviour, wrapped with a
101
+ * clear message)
102
+ */
103
+ export function validateConfig(config = {}) {
104
+ const result = pluginConfigSchema.safeParse(config)
105
+ if (!result.success) {
106
+ const issues = result.error.issues.map(
107
+ (issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`,
108
+ )
109
+ throw new Error(`dsh-worktrees: invalid config — ${issues.join('; ')}`)
110
+ }
111
+ const raw = result.data
112
+ return {
113
+ register: { ...REGISTER_DEFAULTS, ...(raw.register ?? {}) },
114
+ worktreeRoot: raw.worktreeRoot ?? defaultWorktreeRoot(),
115
+ statePath: raw.statePath ?? defaultStatePath(),
116
+ maxWorktrees: raw.maxWorktrees ?? SCALAR_DEFAULTS.maxWorktrees,
117
+ defaultBaseRef: raw.defaultBaseRef ?? SCALAR_DEFAULTS.defaultBaseRef,
118
+ gitTimeoutMs: raw.gitTimeoutMs ?? SCALAR_DEFAULTS.gitTimeoutMs,
119
+ mergeTimeoutMs: raw.mergeTimeoutMs ?? SCALAR_DEFAULTS.mergeTimeoutMs,
120
+ allowedRoots: raw.allowedRoots ?? [],
121
+ requireWorkspaceRegistration:
122
+ raw.requireWorkspaceRegistration ?? SCALAR_DEFAULTS.requireWorkspaceRegistration,
123
+ autoCollect: raw.autoCollect ?? SCALAR_DEFAULTS.autoCollect,
124
+ retainJobHistory: raw.retainJobHistory ?? SCALAR_DEFAULTS.retainJobHistory,
125
+ }
126
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * engine-face — the `worktreesEngine` Cordis service face (DESIGN §10 seam,
3
+ * audit P0: the four-layer contract drift with dsh-dag-orchestrator).
4
+ *
5
+ * WHY THIS MODULE EXISTS. dsh-dag-orchestrator consumes this plugin through
6
+ * the OPPORTUNISTIC service probe `ctx.get('worktreesEngine')`
7
+ * (its lib/worktrees-seam.js admits exactly `{getMergeQueue, getWorktreeService}`
8
+ * as functions). The plugin used to satisfy only the *shape* of that probe
9
+ * while drifting on four behavioural layers the DAG's frozen acceptance
10
+ * tests pin (its test/merge-executor.test.js + test/worktree-task.test.js
11
+ * fake engines). This module adapts the LIVE engine singletons to that
12
+ * consumer contract WITHOUT touching the tool-layer contracts:
13
+ *
14
+ * a) `service.create` — the DAG calls `create({task, repoRoot, baseRef,
15
+ * origin:'dag', correlationId, …})` with NO repoKey/sessionId. The
16
+ * WorktreeService itself now derives repoKey (repoIdFromRoot) and
17
+ * persists origin/correlationId on the record; the facade only has to
18
+ * supply the sessionId the branch name derives from (a stable
19
+ * engine-level id — DAG-created worktrees must not depend on a
20
+ * calling agent session existing).
21
+ *
22
+ * b) `service.findActiveByTask(repoRoot, task)` — implemented directly on
23
+ * the WorktreeService (the reuse probe's lookup); the facade passes
24
+ * it through untouched.
25
+ *
26
+ * c) `queue.enqueue` — the DAG passes the FOUR-key form
27
+ * `{worktreeId, integrationBranch, origin, correlationId}`; git facts
28
+ * (repoKey/repoRoot/sourceBranch/sourceHead) are resolved
29
+ * SERVER-SIDE from the worktree record (the DAG never learns git:
30
+ * sourceHead = the worktree's CURRENT head, sourceBranch = the
31
+ * record's branch). The integration branch is vetted with
32
+ * check-ref-format (audit P1-B) before anything is persisted. The
33
+ * DAG's IDEMPOTENCE contract is implemented here too: while a job for
34
+ * the same (worktreeId, integrationBranch) is still active
35
+ * (queued/applying/conflicted), enqueue RETURNS that job instead of
36
+ * stacking a duplicate — a DAG retry re-polls, it does not re-stack.
37
+ * The queue's own seven-key enqueue stays synchronous and unchanged.
38
+ *
39
+ * d) `queue.drain` — the DAG requires the five-state DrainOutcome
40
+ * `{succeeded|conflicted|failed|no_changes|queued(queued_ahead)}`
41
+ * (its lib/executors/merge.js module header); the internal drain
42
+ * returns the tool-layer `{drained}|{blockedBy}` shape, which must
43
+ * NOT change (worktree_merge maps it). The facade wraps the SAME
44
+ * serial chain — one drain call, two projections. A dirty-source
45
+ guard (audit P3) sits inside the `succeeded` projection: before
46
+ reporting success, the source worktree(s) whose commits were
47
+ integrated are re-checked for uncommitted changes; a dirty worktree
48
+ maps `succeeded` → `failed` / `dirty_not_collected` (the seam twin
49
+ of the tool layer's collect gate), reusing the GitPort's `status`
50
+ porcelain as the shared dirty judgment.
51
+ *
52
+ * Single-writer invariant (red line 10): the facade holds NO state of its
53
+ * own — every method delegates to the SAME store/git-backed service and
54
+ * queue singletons the tools use. This module is glue only.
55
+ */
56
+
57
+ import { assertValidIntegrationBranch, MergeError } from './merge-queue.js'
58
+
59
+ /** Merge-job states that hold the per-branch queue slot (state-store set). */
60
+ const ACTIVE_JOB_STATES = new Set(['queued', 'applying', 'conflicted'])
61
+
62
+ /**
63
+ * The DAG-facing merge queue: the LIVE queue's serial chain with the
64
+ * four-key enqueue completion and the DrainOutcome five-state projection
65
+ * layered on top.
66
+ *
67
+ * @param {object} queue createMergeQueue() product (the tool-layer face)
68
+ * @param {object} store the SAME StateStore the queue was built with
69
+ * @param {object} git the SAME GitPort the queue was built with (head
70
+ * resolution + check-ref-format vetting for the four-key form)
71
+ * @returns {object} the engine-seam queue face (enqueue/drain DAG-adapted;
72
+ * every other member passes through by identity)
73
+ */
74
+ export function createEngineQueueFace(queue, store, git) {
75
+ /**
76
+ * DAG enqueue: complete the seven-key form from the worktree record,
77
+ * vet the integration branch (P1-B), then hand the completed job to the
78
+ * live queue's own enqueue. Idempotence-while-active is checked FIRST.
79
+ */
80
+ /**
81
+ * The dirty-source guard for the DRAIN projection (audit P3 — the seam
82
+ * twin of the tool layer's `dirty_not_collected` collect gate): a source
83
+ * worktree holding uncommitted changes means those changes were NOT in the
84
+ * HEAD the drain just integrated — reporting `succeeded` would be the same
85
+ * "success illusion" the tool layer's collect `dirty_not_collected` stops
86
+ * (lib/merge-queue.js collect, mapped by lib/tools/worktree-merge.js).
87
+ *
88
+ * The DAG four-key path deliberately does NOT auto-collect (the DAG's
89
+ * agent executor owns the worktree's commits, so the seam behaves as the
90
+ * tool layer's autoCollect=false branch): a dirty source is a loud FAIL,
91
+ * the remediation is to commit the work first or drive it through the
92
+ * tool layer's autoCollect.
93
+ *
94
+ * The dirty judgment REUSES the tool layer's own primitive — the GitPort's
95
+ * `git.status()` porcelain (porcelain non-empty == dirty) — rather than
96
+ * re-implementing a parser here (drift guard). A missing worktree record
97
+ * (externally-seeded job) or a status flake fails OPEN toward clean: the
98
+ * merge itself is the integrated state's authority, and an unresolvable
99
+ * status must not fabricate a failure the tool layer would not.
100
+ *
101
+ * @param {object} job a terminal `succeeded` merge-job record.
102
+ * @returns {Promise<{worktreeId: string, error: string} | null>} the first
103
+ * dirty source (error carries `dirty_not_collected`), or null when clean
104
+ * / not determinable.
105
+ */
106
+ async function dirtySourceOf(job) {
107
+ if (!job || typeof job.worktreeId !== 'string' || job.worktreeId.length === 0) return null
108
+ const record = store.worktrees[job.worktreeId]
109
+ if (!record || typeof record.path !== 'string' || record.path.length === 0) return null
110
+ let entries
111
+ try {
112
+ entries = await git.status(record.path)
113
+ } catch {
114
+ return null // status flake / missing tree — fail open (see JSDoc)
115
+ }
116
+ if (!Array.isArray(entries) || entries.length === 0) return null
117
+ return {
118
+ worktreeId: job.worktreeId,
119
+ error:
120
+ 'dirty_not_collected: the worktree ' + JSON.stringify(job.worktreeId) +
121
+ ' has uncommitted changes that were NOT part of the HEAD the drain integrated, ' +
122
+ 'so the merge result silently excluded them; commit this work first ' +
123
+ '(or drive it through the tool layer\'s autoCollect), then re-run the merge',
124
+ }
125
+ }
126
+
127
+ async function enqueue(params = {}) {
128
+ const { worktreeId, integrationBranch } = params
129
+ if (typeof worktreeId !== 'string' || worktreeId.length === 0) {
130
+ throw new MergeError('invalid_params', 'engine enqueue requires a non-empty string worktreeId')
131
+ }
132
+ // Git facts are the provider's business (the DAG never learns git).
133
+ const record = store.worktrees[worktreeId]
134
+ if (record === undefined) {
135
+ throw new MergeError(
136
+ 'worktree_not_found',
137
+ `engine enqueue: no worktree record for worktreeId ${JSON.stringify(worktreeId)}`,
138
+ )
139
+ }
140
+ // The effective branch key: an omitted integrationBranch falls back to
141
+ // the record's own (DESIGN §10 seam — the DAG may omit it). The
142
+ // idempotence scan below compares this SAME effective key whether or not
143
+ // the DAG supplied it, so omitting the field can never widen the match
144
+ // (a defensive branch-dimension check on an undefined value would skip
145
+ // the comparison entirely and let an active job for a DIFFERENT branch
146
+ // on the same worktree be returned).
147
+ const branch = integrationBranch ?? record.integrationBranch
148
+ await assertValidIntegrationBranch(git, record.repoRoot, branch)
149
+
150
+ // Idempotence-while-active: an active job for the same
151
+ // (worktreeId, integrationBranch) is RETURNED, not stacked. The branch
152
+ // dimension always participates (never a bare `integrationBranch !==
153
+ // undefined` guard — the effective key is `branch` above).
154
+ for (const job of Object.values(store.jobs)) {
155
+ if (job.worktreeId !== worktreeId) continue
156
+ if (job.integrationBranch !== branch) continue
157
+ if (!ACTIVE_JOB_STATES.has(job.state)) continue
158
+ return job
159
+ }
160
+
161
+ let sourceHead = record.headCommit
162
+ if (typeof sourceHead !== 'string' || sourceHead.length === 0) {
163
+ try {
164
+ sourceHead = (await git.resolveHead(record.path)).commit
165
+ } catch (error) {
166
+ throw new MergeError(
167
+ 'invalid_params',
168
+ `engine enqueue: cannot resolve the current head of worktree ${worktreeId} ` +
169
+ `(${record.path}): ${error instanceof Error ? error.message : String(error)}`,
170
+ )
171
+ }
172
+ }
173
+ return queue.enqueue({
174
+ repoKey: record.repoKey,
175
+ repoRoot: record.repoRoot,
176
+ integrationBranch: branch,
177
+ worktreeId,
178
+ sourceBranch: record.branch,
179
+ sourceHead,
180
+ ...(params.message !== undefined && params.message !== null ? { message: params.message } : {}),
181
+ origin: params.origin === 'dag' ? 'dag' : 'tool',
182
+ ...(params.correlationId !== undefined && params.correlationId !== null
183
+ ? { correlationId: params.correlationId }
184
+ : {}),
185
+ })
186
+ }
187
+
188
+ /**
189
+ * DAG drain: the five-state DrainOutcome projection of the live drain.
190
+ *
191
+ * Mapping (internal drain outcome → DrainOutcome):
192
+ * - blocked by a conflicted holder → `{conflicted, conflictFiles,
193
+ * integrationWorktree?}` (the retained scene);
194
+ * - blocked by an applying holder (another caller's in-flight apply) →
195
+ * `{queued, queued_ahead}` — not terminal this round, re-poll later;
196
+ * - drained → inspect the branch's terminal jobs: the most recent
197
+ * succeeded → `{succeeded, integratedCommit}`; the most recent
198
+ * failed → `{failed, error}`; NO terminal jobs at all →
199
+ * `{no_changes}` (an empty integration is legal, DESIGN §11.1).
200
+ *
201
+ * `queued_ahead` counts the queued jobs on the branch — the DAG's
202
+ * snake_case contract key (m-5).
203
+ */
204
+ async function drain(repoKey, integrationBranch) {
205
+ const outcome = await queue.drain(repoKey, integrationBranch)
206
+
207
+ // Which jobs exist for this branch, and their settled states.
208
+ const branchJobs = Object.values(store.jobs).filter(
209
+ (job) => job.repoKey === repoKey && job.integrationBranch === integrationBranch,
210
+ )
211
+ const queued = branchJobs.filter((job) => job.state === 'queued')
212
+
213
+ if (outcome !== null && typeof outcome === 'object' && outcome.blockedBy !== undefined) {
214
+ const blocker = outcome.blockedBy
215
+ if (blocker.state === 'conflicted') {
216
+ return {
217
+ state: 'conflicted',
218
+ conflictFiles: Array.isArray(blocker.conflictFiles) ? [...blocker.conflictFiles] : [],
219
+ ...(typeof blocker.integrationWorktree === 'string'
220
+ && blocker.integrationWorktree.length > 0
221
+ ? { integrationWorktree: blocker.integrationWorktree }
222
+ : {}),
223
+ }
224
+ }
225
+ // applying (or an unexpected active state): not terminal this round.
226
+ return { state: 'queued', queued_ahead: queued.length }
227
+ }
228
+
229
+ // Drained (or an empty queue from the start): project the last job's
230
+ // terminal state; no jobs at all → no_changes.
231
+ const terminal = branchJobs
232
+ .filter((job) => job.state === 'succeeded' || job.state === 'failed')
233
+ .sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0))
234
+ if (terminal.length === 0) {
235
+ return { state: 'no_changes' }
236
+ }
237
+ const last = terminal[0]
238
+ if (last.state === 'failed') {
239
+ return { state: 'failed', error: String(last.error ?? 'merge failed') }
240
+ }
241
+ // Dirty-source guard (audit P3): before reporting `succeeded`, confirm
242
+ // the source worktree(s) whose commits were just integrated are CLEAN at
243
+ // drain time. If any terminal succeeded source still holds uncommitted
244
+ // changes, those changes were excluded from the merged HEAD — map to the
245
+ // terminal `failed` / `dirty_not_collected` (the DAG already maps a
246
+ // `failed` DrainOutcome to a transient `dag.merge_failed` retry).
247
+ for (const done of terminal) {
248
+ if (done.state !== 'succeeded') continue
249
+ const dirty = await dirtySourceOf(done)
250
+ if (dirty !== null) {
251
+ return { state: 'failed', error: dirty.error }
252
+ }
253
+ }
254
+ return {
255
+ state: 'succeeded',
256
+ ...(typeof last.integratedCommit === 'string' && last.integratedCommit.length > 0
257
+ ? { integratedCommit: last.integratedCommit }
258
+ : {}),
259
+ }
260
+ }
261
+
262
+ // Everything else passes through BY IDENTITY — the queue's own methods,
263
+ // unchanged (collect/cancel/retry/resolve/listJobs/branchHolders/…).
264
+ return new Proxy(queue, {
265
+ get(target, prop, receiver) {
266
+ if (prop === 'enqueue') return enqueue
267
+ if (prop === 'drain') return drain
268
+ const value = Reflect.get(target, prop, target)
269
+ return typeof value === 'function' ? value.bind(target) : value
270
+ },
271
+ })
272
+ }
273
+
274
+ /**
275
+ * The DAG-facing worktree service: the LIVE service with create() adapted
276
+ * to the DAG call shape.
277
+ *
278
+ * @param {object} service createWorktreeService() product
279
+ * @param {object} opts
280
+ * @param {string} opts.engineSessionId stable session id for DAG-created
281
+ * worktrees (branch names derive from it; defaults to 'dsh-dag-engine')
282
+ * @returns {object} the engine-seam service face
283
+ */
284
+ export function createEngineServiceFace(service, { engineSessionId = 'dsh-dag-engine' } = {}) {
285
+ /**
286
+ * DAG create: fills sessionId (the DAG does not have one) and forwards.
287
+ * repoKey is derived inside the service; origin/correlationId pass
288
+ * through and land on the persisted record (the reuse-ownership gate's
289
+ * evidence).
290
+ */
291
+ async function create(params = {}) {
292
+ return service.create({
293
+ ...params,
294
+ sessionId:
295
+ typeof params.sessionId === 'string' && params.sessionId.length > 0
296
+ ? params.sessionId
297
+ : engineSessionId,
298
+ })
299
+ }
300
+
301
+ return new Proxy(service, {
302
+ get(target, prop, receiver) {
303
+ if (prop === 'create') return create
304
+ const value = Reflect.get(target, prop, target)
305
+ return typeof value === 'function' ? value.bind(target) : value
306
+ },
307
+ })
308
+ }
309
+
310
+ /**
311
+ * Assemble the `worktreesEngine` service value provided on the Cordis ctx.
312
+ *
313
+ * @param {{
314
+ * service: object, queue: object, store: object, git: object,
315
+ * engineSessionId?: string,
316
+ * }} deps the LIVE singletons (the SAME instances the tools use — red
317
+ * line 10: one writer, one store, one serial chain).
318
+ * @returns {{getMergeQueue: () => object, getWorktreeService: () => object, available: true}}
319
+ */
320
+ export function createEngineFace({ service, queue, store, git, engineSessionId } = {}) {
321
+ const queueFace = createEngineQueueFace(queue, store, git)
322
+ const serviceFace = createEngineServiceFace(service, { engineSessionId })
323
+ return {
324
+ getMergeQueue: () => queueFace,
325
+ getWorktreeService: () => serviceFace,
326
+ available: true,
327
+ }
328
+ }