dsh-taskboard 0.6.2 → 0.6.4

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.
@@ -18,12 +18,15 @@ import {
18
18
  newExecutionId,
19
19
  normalizeBody,
20
20
  type ExecutionRecord,
21
+ type ExecutionRepoEvidence,
21
22
  type IsolationMode,
22
23
  type PermissionMode,
23
24
  type TaskModel,
24
25
  type TaskRecord,
25
26
  } from '../shared/protocol.ts'
26
- import { sanitizeBranchName, worktreePathOf, type GitFace, type SettlementFacts } from './git.ts'
27
+ import { sanitizeBranchName, type GitFace, type SettlementFacts } from './git.ts'
28
+ import { isLegacySingle, prepareMirror, type PreparedMirror, type PreparedMirrorRepo } from './isolation.ts'
29
+ import { createRepoScanner, type RepoScanner } from './repos.ts'
27
30
  import { MessageId } from './sdk.ts'
28
31
  import type { TaskStore } from './store.ts'
29
32
 
@@ -94,6 +97,11 @@ export interface ExecutionDeps {
94
97
  * task degrades to the original directory with an isolationNote.
95
98
  */
96
99
  git?: GitFace
100
+ /**
101
+ * Nested-repo scanner for multi-repo mirrors (0.6.3). Absent → a default
102
+ * real-filesystem scanner is built on first use.
103
+ */
104
+ scanner?: RepoScanner
97
105
  /**
98
106
  * Resolve the preset composition for an execution session (0.3.3): hands
99
107
  * the session its tool set. Absent → sessions run on the bare host
@@ -131,15 +139,6 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
131
139
  return { message }
132
140
  }
133
141
 
134
- /** Prepared worktree facts threaded through a live run (settlement evidence). */
135
- export interface PreparedWorktree {
136
- branch: string
137
- worktreePath: string
138
- baseCommit: string
139
- /** True when an existing live worktree was kept as-is (续跑). */
140
- reused?: boolean
141
- }
142
-
143
142
  /** Per-run options. */
144
143
  export interface RunOptions {
145
144
  /**
@@ -153,8 +152,8 @@ export interface RunOptions {
153
152
  /** One live execution tracked for settlement and cancellation. */
154
153
  interface RunEntry {
155
154
  sessionId: string
156
- /** Worktree prepared for this run (evidence collection at ANY settlement). */
157
- prepared?: PreparedWorktree
155
+ /** Task mirror prepared for this run (evidence collection at ANY settlement). */
156
+ prepared?: PreparedMirror
158
157
  settle: () => void
159
158
  dispose: () => Promise<void>
160
159
  }
@@ -193,28 +192,74 @@ export class ExecutionService {
193
192
  }
194
193
 
195
194
  /**
196
- * Best-effort evidence collection for a prepared run (fail-soft: undefined
197
- * on any git problem — settlement NEVER blocks on git).
195
+ * Best-effort evidence collection for a prepared mirror: a repo whose git
196
+ * collect fails is SKIPPED (missing pieces stay unset — settlement NEVER
197
+ * blocks on git); all-fail resolves undefined.
198
198
  */
199
- private async collectEvidence(prepared: PreparedWorktree | undefined): Promise<SettlementFacts | undefined> {
200
- if (prepared === undefined || this.deps.git === undefined) return undefined
201
- try {
202
- return await this.deps.git.collect(prepared.worktreePath, prepared.baseCommit)
203
- } catch {
204
- return undefined
199
+ private async collectEvidence(prepared: PreparedMirror | undefined): Promise<Array<{ repo: PreparedMirrorRepo; facts: SettlementFacts }> | undefined> {
200
+ if (prepared === undefined || this.deps.git === undefined || prepared.repos.length === 0) return undefined
201
+ const out: Array<{ repo: PreparedMirrorRepo; facts: SettlementFacts }> = []
202
+ // The root worktree's status lists its nested child worktrees as untracked
203
+ // noise — exclude them so a fully committed mirror doesn't report fake
204
+ // dirty evidence (0.6.3 review fix).
205
+ const nestedRels = prepared.repos.filter(r => r.repo !== '').map(r => r.repo)
206
+ for (const repo of prepared.repos) {
207
+ try {
208
+ const facts = await this.deps.git.collect(
209
+ repo.worktreePath,
210
+ repo.baseCommit,
211
+ repo.repo === '' && nestedRels.length > 0 ? nestedRels : undefined,
212
+ )
213
+ out.push({ repo, facts })
214
+ } catch {
215
+ /* fail-soft: this repo contributes no evidence */
216
+ }
217
+ }
218
+ return out.length > 0 ? out : undefined
219
+ }
220
+
221
+ /** Map one repo's settlement facts onto evidence record fields. */
222
+ private factsFields(facts: SettlementFacts): Omit<ExecutionRepoEvidence, 'repo' | 'branch' | 'worktreePath' | 'baseCommit'> {
223
+ return {
224
+ ...(facts.headCommit !== undefined ? { headCommit: facts.headCommit } : {}),
225
+ commits: facts.commits,
226
+ commitsTotal: facts.commitsTotal,
227
+ dirtyFiles: facts.dirtyFiles,
228
+ dirtyFilesTotal: facts.dirtyFilesTotal,
229
+ changedFiles: facts.changedFiles,
230
+ ...(facts.diffStat !== undefined ? { diffStat: facts.diffStat } : {}),
205
231
  }
206
232
  }
207
233
 
208
- /** Copy collected facts onto an execution record (in place). */
209
- private applyFacts(execution: ExecutionRecord, facts: SettlementFacts | undefined): void {
210
- if (facts === undefined) return
211
- if (facts.headCommit !== undefined) execution.headCommit = facts.headCommit
212
- execution.commits = facts.commits
213
- execution.commitsTotal = facts.commitsTotal
214
- execution.dirtyFiles = facts.dirtyFiles
215
- execution.dirtyFilesTotal = facts.dirtyFilesTotal
216
- execution.changedFiles = facts.changedFiles
217
- if (facts.diffStat !== undefined) execution.diffStat = facts.diffStat
234
+ /**
235
+ * Copy collected facts onto an execution record (in place). The legacy
236
+ * flat fields always carry the FIRST repo (the workspace root when it has
237
+ * one) so single-repo records stay byte-identical to the pre-mirror shape;
238
+ * non-legacy mirrors additionally fill the per-repo `repos` evidence.
239
+ */
240
+ private applyFacts(
241
+ execution: ExecutionRecord,
242
+ prepared: PreparedMirror | undefined,
243
+ evidence: Array<{ repo: PreparedMirrorRepo; facts: SettlementFacts }> | undefined,
244
+ ): void {
245
+ if (evidence === undefined || evidence.length === 0) return
246
+ const first = evidence[0]!.facts
247
+ if (first.headCommit !== undefined) execution.headCommit = first.headCommit
248
+ execution.commits = first.commits
249
+ execution.commitsTotal = first.commitsTotal
250
+ execution.dirtyFiles = first.dirtyFiles
251
+ execution.dirtyFilesTotal = first.dirtyFilesTotal
252
+ execution.changedFiles = first.changedFiles
253
+ if (first.diffStat !== undefined) execution.diffStat = first.diffStat
254
+ if (prepared !== undefined && !isLegacySingle(prepared)) {
255
+ execution.repos = evidence.map(({ repo, facts }) => ({
256
+ repo: repo.repo,
257
+ branch: repo.branch,
258
+ worktreePath: repo.worktreePath,
259
+ baseCommit: repo.baseCommit,
260
+ ...this.factsFields(facts),
261
+ }))
262
+ }
218
263
  }
219
264
 
220
265
  /**
@@ -228,7 +273,7 @@ export class ExecutionService {
228
273
  // The failed session may already have committed work — collect the
229
274
  // evidence (best effort) BEFORE marking the execution failed (0.3.1).
230
275
  const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)
231
- return this.collectEvidence(entry?.prepared).then(facts =>
276
+ return this.collectEvidence(entry?.prepared).then(evidence =>
232
277
  this.deps.store.mutate('execution-recorded', (ledger) => {
233
278
  for (const task of ledger.tasks) {
234
279
  for (const execution of task.executions) {
@@ -236,7 +281,7 @@ export class ExecutionService {
236
281
  execution.outcome = 'failed'
237
282
  execution.error = message.slice(0, 500)
238
283
  execution.endedAt = this.deps.now()
239
- this.applyFacts(execution, facts)
284
+ this.applyFacts(execution, entry?.prepared, evidence)
240
285
  // The failed session will not finish the work: hand the task back
241
286
  // instead of leaving it stuck in in_progress forever — and leave a
242
287
  // system comment so the GUI shows why.
@@ -315,7 +360,6 @@ export class ExecutionService {
315
360
  // the original directory fail-soft on any git problem.
316
361
  const isolation: IsolationMode = effectiveIsolation(task)
317
362
  const branch = task.branch ?? sanitizeBranchName(task.title, task.id)
318
- const worktreePath = worktreePathOf(workspace.path, task.id)
319
363
 
320
364
  // 1. Open the execution record, flip the card to in_progress, and record
321
365
  // the executing session as the claim holder — atomically.
@@ -356,23 +400,39 @@ export class ExecutionService {
356
400
 
357
401
  // 1b. Worktree preparation (fail-soft): any failure degrades this run to
358
402
  // the original directory with an isolationNote — the ledger and the
359
- // execution pipeline itself never fail over git.
403
+ // execution pipeline itself never fail over git. 0.6.3: preparation
404
+ // builds a whole-workspace MIRROR (root repo + nested repos); a plain
405
+ // single-repo workspace keeps the legacy record shape everywhere.
360
406
  let isolationNote: string | undefined
361
- let prepared: PreparedWorktree | undefined
407
+ let prepared: PreparedMirror | undefined
362
408
  if (isolation === 'worktree') {
363
- const outcome = await this.prepareIsolation(task, workspace.path, worktreePath, branch, options?.reuseWorktree === true)
364
- if (outcome.prepared !== undefined) {
365
- prepared = outcome.prepared
366
- // Persist the isolation facts of the run (branch is already on the
367
- // record from the gate mutation).
368
- await this.patchExecution(executionId, {
369
- worktreePath: outcome.prepared.worktreePath,
370
- baseCommit: outcome.prepared.baseCommit,
371
- })
372
- } else {
373
- isolationNote = outcome.note
374
- // Degraded run: clear the optimistic worktree markers.
409
+ if (this.deps.git === undefined) {
410
+ isolationNote = 'git 集成不可用,已在原目录执行'
375
411
  await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
412
+ } else {
413
+ const outcome = await prepareMirror(
414
+ { git: this.deps.git, scanner: this.deps.scanner ?? createRepoScanner() },
415
+ { workspacePath: workspace.path, taskId: task.id, branch, reuse: options?.reuseWorktree === true },
416
+ )
417
+ if ('mirror' in outcome) {
418
+ prepared = outcome.mirror
419
+ await this.pinBranches(task, prepared)
420
+ // Persist the isolation facts of the run (branch is already on the
421
+ // record from the gate mutation). The root repo keeps the legacy
422
+ // flat fields; non-legacy mirrors also record per-repo entries.
423
+ const root = prepared.repos[0]
424
+ await this.patchExecution(executionId, {
425
+ worktreePath: root?.worktreePath,
426
+ baseCommit: root?.baseCommit,
427
+ ...(!isLegacySingle(prepared)
428
+ ? { repos: prepared.repos.map(r => ({ repo: r.repo, branch: r.branch, worktreePath: r.worktreePath, baseCommit: r.baseCommit })) }
429
+ : {}),
430
+ })
431
+ } else {
432
+ isolationNote = outcome.note
433
+ // Degraded run: clear the optimistic worktree markers.
434
+ await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
435
+ }
376
436
  }
377
437
  }
378
438
 
@@ -397,9 +457,7 @@ export class ExecutionService {
397
457
  await this.patchExecution(executionId, { outcome: 'failed', error: `preset 组合失败:${message.slice(0, 400)}`, endedAt: this.deps.now() })
398
458
  await this.revertProgress(taskId)
399
459
  // S1: a run that never started must not leave its worktree behind.
400
- if (prepared !== undefined && this.deps.git !== undefined) {
401
- try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort (dirty worktrees are kept) */ }
402
- }
460
+ await this.cleanupMirror(prepared, workspace.path)
403
461
  return { ok: false, error: `preset composition failed: ${message}` }
404
462
  }
405
463
  let handle: Awaited<ReturnType<AgentsFace['create']>>
@@ -425,9 +483,7 @@ export class ExecutionService {
425
483
  await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })
426
484
  await this.revertProgress(taskId)
427
485
  // S1: a run that never started must not leave its worktree behind.
428
- if (prepared !== undefined && this.deps.git !== undefined) {
429
- try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort (dirty worktrees are kept) */ }
430
- }
486
+ await this.cleanupMirror(prepared, workspace.path)
431
487
  return { ok: false, error: message }
432
488
  }
433
489
 
@@ -443,9 +499,7 @@ export class ExecutionService {
443
499
  if (!stillRunning) {
444
500
  await handle.dispose().catch(() => { /* best effort */ })
445
501
  // S1: do not leave the startup artifacts behind a cancelled run either.
446
- if (prepared !== undefined && this.deps.git !== undefined) {
447
- try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort */ }
448
- }
502
+ await this.cleanupMirror(prepared, workspace.path)
449
503
  return { ok: false, error: 'cancelled during startup' }
450
504
  }
451
505
 
@@ -522,9 +576,9 @@ export class ExecutionService {
522
576
  private async settleExecution(
523
577
  executionId: string,
524
578
  sessionId: string,
525
- prepared: PreparedWorktree | undefined,
579
+ prepared: PreparedMirror | undefined,
526
580
  ): Promise<void> {
527
- const facts = await this.collectEvidence(prepared)
581
+ const evidence = await this.collectEvidence(prepared)
528
582
  await this.deps.store.mutate('execution-recorded', (ledger) => {
529
583
  for (const t of ledger.tasks) {
530
584
  const execution = t.executions.find(e => e.id === executionId)
@@ -532,7 +586,7 @@ export class ExecutionService {
532
586
  const now = this.deps.now()
533
587
  execution.outcome = 'succeeded'
534
588
  execution.endedAt = now
535
- this.applyFacts(execution, facts)
589
+ this.applyFacts(execution, prepared, evidence)
536
590
  if (t.status === 'in_progress' && t.claimedBy === sessionId) {
537
591
  delete t.claimedBy
538
592
  delete t.claimedAt
@@ -559,57 +613,60 @@ export class ExecutionService {
559
613
  }
560
614
 
561
615
  /**
562
- * Prepare the dedicated worktree for a run (fail-soft): detect git, then
563
- * create/reset the fixed task branch fresh baseline, or keep a live
564
- * worktree as-is for 续跑. On success the branch name is pinned onto the
565
- * task once (renames never change it); on any failure the run degrades
566
- * with a human-readable note.
616
+ * Pin branch names at FIRST successful creation (§9: 改名不改分支) the
617
+ * workspace root repo onto the legacy `branch` field, every nested repo
618
+ * into the `branches` map. Re-checked inside the mutation (the task may
619
+ * have moved between preparation and commit).
567
620
  */
568
- private async prepareIsolation(
569
- task: TaskRecord,
570
- workspacePath: string,
571
- worktreePath: string,
572
- branch: string,
573
- reuse: boolean,
574
- ): Promise<{ prepared?: PreparedWorktree; note?: string }> {
575
- const git = this.deps.git
576
- if (git === undefined) return { note: 'git 集成不可用,已在原目录执行' }
577
- let inside = false
578
- try {
579
- inside = await git.detect(workspacePath)
580
- } catch { /* fail-soft */ }
581
- if (!inside) {
582
- // Distinguish 未装 git from 非 git 仓库 (0.3.1): probe the binary so the
583
- // degradation note names the real cause.
584
- let hasBinary = true
585
- try {
586
- hasBinary = await git.binaryAvailable()
587
- } catch { /* fail-soft → treat as repo-side */ }
588
- return { note: hasBinary ? '当前项目不是 git 仓库,已在原目录执行' : 'git 不可用(未安装或不在 PATH),已在原目录执行' }
621
+ private async pinBranches(task: TaskRecord, mirror: PreparedMirror): Promise<void> {
622
+ const wanted: Array<{ repo: string; branch: string }> = []
623
+ for (const repo of mirror.repos) {
624
+ if (repo.repo === '') {
625
+ if (task.branch === undefined) wanted.push({ repo: '', branch: repo.branch })
626
+ } else if (task.branches?.[repo.repo] === undefined) {
627
+ wanted.push({ repo: repo.repo, branch: repo.branch })
628
+ }
589
629
  }
590
- let info
591
- try {
592
- info = await git.prepareWorktree(workspacePath, worktreePath, branch, reuse ? 'reuse' : 'fresh')
593
- } catch { /* fail-soft */ }
594
- if (info === undefined) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }
595
- // Pin the branch name at first SUCCESSFUL creation (§9: 改名不改分支).
596
- if (task.branch === undefined) {
597
- await this.deps.store.mutate('task-updated', (ledger) => {
598
- const target = ledger.tasks.find(t => t.id === task.id)
599
- if (target !== undefined && target.branch === undefined) {
600
- target.branch = branch
601
- return [target]
630
+ if (wanted.length === 0) return
631
+ await this.deps.store.mutate('task-updated', (ledger) => {
632
+ const target = ledger.tasks.find(t => t.id === task.id)
633
+ if (target === undefined) return undefined
634
+ let touched = false
635
+ for (const w of wanted) {
636
+ if (w.repo === '') {
637
+ if (target.branch === undefined) {
638
+ target.branch = w.branch
639
+ touched = true
640
+ }
641
+ } else if (target.branches?.[w.repo] === undefined) {
642
+ target.branches = { ...target.branches, [w.repo]: w.branch }
643
+ touched = true
602
644
  }
603
- return undefined
604
- })
605
- }
606
- return {
607
- prepared: {
608
- branch: info.branch,
609
- worktreePath: info.path,
610
- baseCommit: info.baseCommit,
611
- ...(info.reused === true ? { reused: true } : {}),
612
- },
645
+ }
646
+ return touched ? [target] : undefined
647
+ })
648
+ }
649
+
650
+ /**
651
+ * Best-effort mirror teardown after a failed start (S1): each repo's
652
+ * worktree is removed through its OWN repo root; dirty worktrees are kept
653
+ * never a data-loss primitive.
654
+ */
655
+ private async cleanupMirror(mirror: PreparedMirror | undefined, workspacePath: string): Promise<void> {
656
+ if (mirror === undefined || this.deps.git === undefined) return
657
+ // Children first (removeMirror's rule): the root worktree's status shows
658
+ // its still-present child worktrees as untracked, so removing it first
659
+ // hits a false dirty-worktree refusal and leaves residue behind. The root
660
+ // gets the noise exemption but NO force: a reused worktree's real agent
661
+ // dirt must keep it alive. (Structural-noise residue stays recoverable
662
+ // through the routes' aggregated mirror removal.)
663
+ const nestedRels = mirror.repos.filter(r => r.repo !== '').map(r => r.repo)
664
+ for (const repo of [...mirror.repos].reverse()) {
665
+ const root = repo.repo === '' ? workspacePath : workspacePath + '/' + repo.repo
666
+ try {
667
+ await this.deps.git.removeWorktree(root, repo.worktreePath,
668
+ repo.repo === '' && nestedRels.length > 0 ? { exempt: nestedRels } : undefined)
669
+ } catch { /* best effort (dirty worktrees are kept) */ }
613
670
  }
614
671
  }
615
672
 
@@ -641,7 +698,7 @@ export class ExecutionService {
641
698
 
642
699
  // The cancelled session may already have committed work — keep the
643
700
  // evidence (best effort) so the user can inspect or 续跑 (0.3.1).
644
- const facts = await this.collectEvidence(entry?.prepared)
701
+ const evidence = await this.collectEvidence(entry?.prepared)
645
702
  let settled = false
646
703
  await this.deps.store.mutate('execution-recorded', (ledger) => {
647
704
  const target = ledger.tasks.find(t => t.id === taskId)
@@ -651,7 +708,7 @@ export class ExecutionService {
651
708
  settled = true
652
709
  execution.outcome = 'cancelled'
653
710
  execution.endedAt = this.deps.now()
654
- this.applyFacts(execution, facts)
711
+ this.applyFacts(execution, entry?.prepared, evidence)
655
712
  if (target.status === 'in_progress') {
656
713
  target.status = 'todo'
657
714
  target.updatedAt = this.deps.now()
@@ -708,10 +765,10 @@ export class ExecutionService {
708
765
  * the user reviews at merge time); 续跑 and degraded runs each add their
709
766
  * own steering line (0.3.1).
710
767
  * @param task - the task.
711
- * @param prepared - worktree facts when this run is isolated.
768
+ * @param prepared - the task mirror when this run is isolated.
712
769
  * @param degradeNote - why a worktree task degraded to the main directory.
713
770
  */
714
- private pluginFraming(task: TaskRecord, prepared?: PreparedWorktree, degradeNote?: string): string {
771
+ private pluginFraming(task: TaskRecord, prepared?: PreparedMirror, degradeNote?: string): string {
715
772
  let text = `【任务看板】${task.title}(ID: ${task.id})\n`
716
773
  + `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
717
774
  + `完成后按序交接:\n`
@@ -728,10 +785,16 @@ export class ExecutionService {
728
785
  text += `\n本任务有验收清单(DoD,${done}/${task.checklist.length} 已完成)——按清单干活:\n${items}\n完成一项就用 taskboard_checklist(action=check,附 note 证据)勾选;未完成项会在验收时高亮,全部完成再移待验收。需要补充验收项也可用 action=add 追加。`
729
786
  }
730
787
  if (prepared !== undefined) {
731
- if (prepared.reused === true) {
732
- text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\n${prepared.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`
788
+ if (isLegacySingle(prepared)) {
789
+ // Byte-identical legacy single-repo steering (0.3.0–0.6.2 wording).
790
+ const only = prepared.repos[0]!
791
+ if (only.reused === true) {
792
+ text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${only.branch} 的 worktree——\n${only.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`
793
+ } else {
794
+ text += `\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${only.branch} 的全新 worktree——\n${only.worktreePath}\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`
795
+ }
733
796
  } else {
734
- text += `\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${prepared.branch} 的全新 worktree——\n${prepared.worktreePath}\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`
797
+ text += this.mirrorFraming(prepared)
735
798
  }
736
799
  } else if (degradeNote !== undefined) {
737
800
  text += `\n⚠ 本次执行未能建立隔离,正在主项目目录中工作(原因:${degradeNote})。该目录可能有他人未提交的改动:动手前先 git status 检查现状,改动尽量集中,结束时在评论中说明动了哪些文件;避免把未经验证的改动直接提交到主分支。`
@@ -739,6 +802,27 @@ export class ExecutionService {
739
802
  return text
740
803
  }
741
804
 
805
+ /**
806
+ * The multi-repo mirror section of the framing line (0.6.3): per-repo
807
+ * checkout list, the (possibly partial) coverage boundary, per-repo commit
808
+ * discipline, and the 禁改 list for repos that failed to mirror.
809
+ */
810
+ private mirrorFraming(mirror: PreparedMirror): string {
811
+ const mode = mirror.allReused ? '续跑' : '全新'
812
+ const lines = mirror.repos
813
+ .map(r => `- ${r.repo === '' ? '根仓库' : r.repo} → ${r.worktreePath}(分支 ${r.branch}${r.reused === true ? ',续跑' : ''})`)
814
+ .join('\n')
815
+ let text = `\n本任务启用了 Git Worktree 隔离(多仓库镜像模式,本次${mode}):整个工作区已镜像到任务目录——\n${mirror.root}\n各仓库检出位置与任务分支(每仓库各一个同名任务分支):\n${lines}\n(全新检出的镜像不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述任务目录内对应仓库的镜像里——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动镜像之外的任何文件;改动发生在哪个仓库,就把完成的工作提交(git commit)到那个仓库的任务分支,验收将按仓库合并各分支的提交记录。`
816
+ if (mirror.skipped.length > 0) {
817
+ const skipped = mirror.skipped.map(s => `- ${s.repo}(原因:${s.reason})`).join('\n')
818
+ text += `\n⚠ 以下仓库未能建立镜像:\n${skipped}\n本次执行严禁改动这些仓库的主目录。`
819
+ }
820
+ if (mirror.allReused) {
821
+ text += `\n本次为续跑:各仓库上一次执行的改动与提交都保留在镜像原处——动手前先在各仓库镜像里查看已有改动(git status / git log),避免重复劳动。`
822
+ }
823
+ return text
824
+ }
825
+
742
826
  /**
743
827
  * The card body as a normal user bubble: the effective prompt (title+
744
828
  * description, with the explicit prompt appended when set) with template
@@ -774,4 +858,4 @@ export class ExecutionService {
774
858
  return undefined
775
859
  })
776
860
  }
777
- }
861
+ }
package/src/host/git.ts CHANGED
@@ -34,6 +34,30 @@ const HEAVY_TIMEOUT_MS = 15_000
34
34
  /** Directory under a workspace where task worktrees live. */
35
35
  export const WORKTREE_DIR = '.dsh-worktrees'
36
36
 
37
+ /**
38
+ * The path segment of one `status --porcelain` line. Shape-aware: the RAW
39
+ * line is `XY path` (path at index 3) while the plugin's trimmed evidence
40
+ * lines collapse a leading-space status to `X path` (path at index 2) —
41
+ * slicing a fixed 3 misparses exactly the gitlink/unstaged shapes a
42
+ * multi-repo mirror produces (` M sub-repo`), 0.6.3 review fix.
43
+ */
44
+ export function statusLinePath(line: string): string {
45
+ if (line.length >= 3 && line[2] === ' ') return line.slice(3)
46
+ if (line.length >= 2 && line[1] === ' ') return line.slice(2)
47
+ return line
48
+ }
49
+
50
+ /**
51
+ * Whether a `status --porcelain` line targets one of `rels` (a repo-relative
52
+ * path) or anything under it. Porcelain prints untracked directories with a
53
+ * trailing slash (`?? sub/`), so all three shapes match. Empty rels never
54
+ * match (0.6.3 review fix).
55
+ */
56
+ export function statusLineUnder(line: string, rels: readonly string[]): boolean {
57
+ const p = statusLinePath(line)
58
+ return rels.some(rel => rel.length > 0 && (p === rel || p === rel + '/' || p.startsWith(rel + '/')))
59
+ }
60
+
37
61
  /** Evidence caps: commits kept per execution record (newest first). */
38
62
  export const MAX_COMMIT_EVIDENCE = 50
39
63
 
@@ -118,18 +142,40 @@ export interface GitFace {
118
142
  * undefined on any failure — callers degrade to the original directory.
119
143
  */
120
144
  prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>
121
- /** Collect settlement facts (never throws; missing pieces stay unset). */
122
- collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>
123
- /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */
124
- merge(root: string, branch: string): Promise<void>
145
+ /**
146
+ * Collect settlement facts (never throws; missing pieces stay unset).
147
+ * `excludeRelPaths` drops `status --porcelain` lines targeting those
148
+ * repo-relative paths (or anything under them) — the mirror's NESTED repo
149
+ * worktrees read as untracked noise in the root worktree's status and are
150
+ * not this repo's uncommitted changes (0.6.3 review fix).
151
+ */
152
+ collect(worktreePath: string, baseCommit: string, excludeRelPaths?: readonly string[]): Promise<SettlementFacts>
153
+ /**
154
+ * Uncommitted-change lines of a working tree ([] = clean; undefined on git
155
+ * failure). Mirror removal pre-checks EVERY repo worktree before deleting
156
+ * any, so one dirty repo refuses the whole mirror (0.6.3).
157
+ */
158
+ dirtyLines(cwd: string): Promise<string[] | undefined>
159
+ /**
160
+ * Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable
161
+ * reason. `exemptRelPaths` extends the main-clean exemption beyond
162
+ * `.dsh-worktrees`: uncommitted-shape noise under those repo-relative paths
163
+ * (parallel repos are self-governing — their content AND their gitlink
164
+ * entries don't gate the root repo's merge, 0.6.3 review fix).
165
+ */
166
+ merge(root: string, branch: string, exemptRelPaths?: readonly string[]): Promise<void>
125
167
  /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */
126
168
  isAncestor(root: string, branch: string): Promise<boolean>
127
169
  /**
128
170
  * Remove a worktree. Resolves 'removed' on success, 'unregistered' when git
129
171
  * no longer knows the path (an orphaned directory). THROWS when it still
130
172
  * has uncommitted changes, or on any other git failure (readable reason).
173
+ * `opts.exempt` drops mirror-structural noise from the dirty check and
174
+ * `opts.force` lets `git worktree remove` accept it — for callers that have
175
+ * ALREADY aggregated the real-dirty check themselves (the mirror removal:
176
+ * noise-exempt clean → force is safe; real dirt never gets here).
131
177
  */
132
- removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>
178
+ removeWorktree(root: string, worktreePath: string, opts?: { exempt?: readonly string[]; force?: boolean }): Promise<'removed' | 'unregistered'>
133
179
  /** Delete a branch; THROWS (e.g. still checked out in a worktree). */
134
180
  deleteBranch(root: string, branch: string): Promise<void>
135
181
  /**
@@ -266,7 +312,7 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
266
312
  return { path, branch, baseCommit }
267
313
  }),
268
314
 
269
- async collect(worktreePath, baseCommit) {
315
+ async collect(worktreePath, baseCommit, excludeRelPaths) {
270
316
  const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }
271
317
  const range = `${baseCommit}..HEAD`
272
318
 
@@ -292,7 +338,14 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
292
338
 
293
339
  const status = await quick(['status', '--porcelain'], worktreePath)
294
340
  if (status.ok) {
295
- const dirty = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
341
+ // excludeRelPaths drops the mirror's nested repo worktrees they
342
+ // read as untracked noise / gitlink drift here, not as this repo's
343
+ // uncommitted changes. Excluded on the RAW line (path extraction is
344
+ // shape-aware); the stored evidence keeps its trimmed 0.3.x shape.
345
+ const dirty = status.stdout.split('\n')
346
+ .filter(l => l.trim().length > 0)
347
+ .filter(l => !statusLineUnder(l, excludeRelPaths ?? []))
348
+ .map(l => l.trim())
296
349
  facts.dirtyFilesTotal = dirty.length
297
350
  facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)
298
351
  }
@@ -306,19 +359,22 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
306
359
  return facts
307
360
  },
308
361
 
309
- merge: (root, branch) => withRootLock(root, async () => {
362
+ async dirtyLines(cwd) {
363
+ const status = await quick(['status', '--porcelain'], cwd)
364
+ if (!status.ok) return undefined
365
+ return status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
366
+ },
367
+
368
+ merge: (root, branch, exemptRelPaths) => withRootLock(root, async () => {
310
369
  // Main-clean check. The plugin's own worktree directory
311
370
  // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —
312
371
  // otherwise merging would be impossible without gitignoring it first.
313
372
  const status = await quick(['status', '--porcelain'], root)
314
373
  if (status.ok) {
315
374
  const dirtyLines = status.stdout.split('\n')
375
+ .filter(l => l.trim().length > 0)
376
+ .filter(l => !statusLineUnder(l, [WORKTREE_DIR, ...(exemptRelPaths ?? [])]))
316
377
  .map(l => l.trim())
317
- .filter(l => {
318
- if (l.length === 0) return false
319
- const path = l.slice(3)
320
- return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)
321
- })
322
378
  if (dirtyLines.length > 0) {
323
379
  // Machine-readable tag: callers classify without parsing zh-CN text.
324
380
  throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })
@@ -339,14 +395,24 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
339
395
  return r.ok
340
396
  },
341
397
 
342
- removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
398
+ removeWorktree: (root, worktreePath, opts) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
343
399
  const status = await quick(['status', '--porcelain'], worktreePath)
344
400
  if (status.ok && status.stdout.trim().length > 0) {
345
- const lines = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
346
- // Machine-readable tag: purge flows classify without parsing zh-CN text.
347
- throw Object.assign(new Error(`worktree ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
401
+ // opts.exempt drops mirror-structural noise (nested child worktrees /
402
+ // gitlink drift) the caller's aggregated check already refused real
403
+ // dirt before reaching here.
404
+ const lines = status.stdout.split('\n')
405
+ .filter(l => l.trim().length > 0)
406
+ .filter(l => !statusLineUnder(l, opts?.exempt ?? []))
407
+ .map(l => l.trim())
408
+ if (lines.length > 0) {
409
+ // Machine-readable tag: purge flows classify without parsing zh-CN text.
410
+ throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
411
+ }
348
412
  }
349
- const removed = await heavy(['worktree', 'remove', worktreePath], root)
413
+ const removed = await heavy(opts?.force === true
414
+ ? ['worktree', 'remove', '--force', worktreePath]
415
+ : ['worktree', 'remove', worktreePath], root)
350
416
  if (removed.ok) return 'removed'
351
417
  // S3: classify the failure WITHOUT parsing git's (localizable) stderr —
352
418
  // a path absent from `worktree list` is an unregistered leftover, not