dsh-taskboard 0.2.2 → 0.3.3

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.
@@ -11,13 +11,16 @@
11
11
  * @module dsh-taskboard/host/execution
12
12
  */
13
13
  import {
14
+ effectiveIsolation,
14
15
  effectivePrompt,
15
16
  newCommentId,
16
17
  newExecutionId,
17
18
  normalizeBody,
18
19
  type ExecutionRecord,
20
+ type IsolationMode,
19
21
  type TaskRecord,
20
22
  } from '../shared/protocol.ts'
23
+ import { sanitizeBranchName, worktreePathOf, type GitFace, type SettlementFacts } from './git.ts'
21
24
  import { MessageId } from './sdk.ts'
22
25
  import type { TaskStore } from './store.ts'
23
26
 
@@ -28,8 +31,10 @@ export const DEFAULT_MAX_CONCURRENT = 3
28
31
  export interface AgentsFace {
29
32
  create(options: {
30
33
  sessionId: string
31
- meta?: { cwd?: string }
34
+ meta?: { cwd?: string; agentPreset?: string }
32
35
  agentOptions?: { provider?: string; model?: string }
36
+ /** Preset composition callback: mounts tools/persona into the agent's scoped context. */
37
+ setup?: (agentCtx: unknown) => Promise<void> | void
33
38
  }): Promise<{
34
39
  agent: {
35
40
  id: string
@@ -41,6 +46,18 @@ export interface AgentsFace {
41
46
  }>
42
47
  }
43
48
 
49
+ /**
50
+ * The preset composition an execution session is built from — the shape
51
+ * apiproxy's ensureSession produces: resolve → record on the session header,
52
+ * mount → inside agents.create's setup callback.
53
+ */
54
+ export interface AgentComposition {
55
+ /** The resolved preset id recorded on the session header. */
56
+ agentPreset: string
57
+ /** Mounts the preset's plugins (tools, persona) into the agent's scope. */
58
+ setup: (agentCtx: unknown) => Promise<void> | void
59
+ }
60
+
44
61
  /** Narrow workspaces face for execution. */
45
62
  export interface ExecutionWorkspaceFace {
46
63
  get(id: string): { id: string; path: string } | undefined
@@ -69,6 +86,19 @@ export interface ExecutionDeps {
69
86
  renameSession?: (sessionId: string, title: string) => void
70
87
  /** Max concurrently running executions across all tasks (default 3). */
71
88
  maxConcurrent?: number
89
+ /**
90
+ * Git face for worktree isolation (0.3.0). Absent → every worktree-mode
91
+ * task degrades to the original directory with an isolationNote.
92
+ */
93
+ git?: GitFace
94
+ /**
95
+ * Resolve the preset composition for an execution session (0.3.3): hands
96
+ * the session its tool set. Absent → sessions run on the bare host
97
+ * composition (pre-preset behavior). A rejection fails the run through
98
+ * the existing failure path — a broken preset never yields a half-composed
99
+ * session (same rollback semantics as apiproxy).
100
+ */
101
+ composeAgent?: (presetId?: string) => Promise<AgentComposition | undefined>
72
102
  }
73
103
 
74
104
  /** Outcome of a run request (immediate; the run settles asynchronously). */
@@ -96,9 +126,30 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
96
126
  return { message }
97
127
  }
98
128
 
129
+ /** Prepared worktree facts threaded through a live run (settlement evidence). */
130
+ export interface PreparedWorktree {
131
+ branch: string
132
+ worktreePath: string
133
+ baseCommit: string
134
+ /** True when an existing live worktree was kept as-is (续跑). */
135
+ reused?: boolean
136
+ }
137
+
138
+ /** Per-run options. */
139
+ export interface RunOptions {
140
+ /**
141
+ * 续跑: keep a live worktree/branch exactly as-is (the previous agent's
142
+ * commits and uncommitted changes survive) instead of resetting to the
143
+ * main HEAD. Falls back to a fresh preparation when none is alive.
144
+ */
145
+ reuseWorktree?: boolean
146
+ }
147
+
99
148
  /** One live execution tracked for settlement and cancellation. */
100
149
  interface RunEntry {
101
150
  sessionId: string
151
+ /** Worktree prepared for this run (evidence collection at ANY settlement). */
152
+ prepared?: PreparedWorktree
102
153
  settle: () => void
103
154
  dispose: () => Promise<void>
104
155
  }
@@ -119,35 +170,66 @@ export class ExecutionService {
119
170
  })
120
171
  }
121
172
 
173
+ /**
174
+ * Best-effort evidence collection for a prepared run (fail-soft: undefined
175
+ * on any git problem — settlement NEVER blocks on git).
176
+ */
177
+ private async collectEvidence(prepared: PreparedWorktree | undefined): Promise<SettlementFacts | undefined> {
178
+ if (prepared === undefined || this.deps.git === undefined) return undefined
179
+ try {
180
+ return await this.deps.git.collect(prepared.worktreePath, prepared.baseCommit)
181
+ } catch {
182
+ return undefined
183
+ }
184
+ }
185
+
186
+ /** Copy collected facts onto an execution record (in place). */
187
+ private applyFacts(execution: ExecutionRecord, facts: SettlementFacts | undefined): void {
188
+ if (facts === undefined) return
189
+ if (facts.headCommit !== undefined) execution.headCommit = facts.headCommit
190
+ execution.commits = facts.commits
191
+ execution.commitsTotal = facts.commitsTotal
192
+ execution.dirtyFiles = facts.dirtyFiles
193
+ execution.dirtyFilesTotal = facts.dirtyFilesTotal
194
+ execution.changedFiles = facts.changedFiles
195
+ if (facts.diffStat !== undefined) execution.diffStat = facts.diffStat
196
+ }
197
+
122
198
  /** Record a turn failure against the running execution of that session and give the task back. */
123
199
  private noteFailure(sessionId: string, message: string): void {
124
- void this.deps.store.mutate('execution-recorded', (ledger) => {
125
- for (const task of ledger.tasks) {
126
- for (const execution of task.executions) {
127
- if (execution.sessionId === sessionId && execution.outcome === 'running') {
128
- execution.outcome = 'failed'
129
- execution.error = message.slice(0, 500)
130
- execution.endedAt = this.deps.now()
131
- // The failed session will not finish the work: hand the task back
132
- // instead of leaving it stuck in in_progress forever — and leave a
133
- // system comment so the GUI shows why.
134
- if (task.status === 'in_progress' && task.claimedBy === sessionId) {
135
- task.status = 'todo'
136
- task.updatedAt = this.deps.now()
137
- delete task.claimedBy
138
- delete task.claimedAt
139
- task.comments.push({
140
- id: newCommentId(),
141
- body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
142
- version: 1,
143
- createdAt: this.deps.now(),
144
- })
200
+ // The failed session may already have committed work — collect the
201
+ // evidence (best effort) BEFORE marking the execution failed (0.3.1).
202
+ const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)
203
+ void this.collectEvidence(entry?.prepared).then(facts => {
204
+ void this.deps.store.mutate('execution-recorded', (ledger) => {
205
+ for (const task of ledger.tasks) {
206
+ for (const execution of task.executions) {
207
+ if (execution.sessionId === sessionId && execution.outcome === 'running') {
208
+ execution.outcome = 'failed'
209
+ execution.error = message.slice(0, 500)
210
+ execution.endedAt = this.deps.now()
211
+ this.applyFacts(execution, facts)
212
+ // The failed session will not finish the work: hand the task back
213
+ // instead of leaving it stuck in in_progress forever — and leave a
214
+ // system comment so the GUI shows why.
215
+ if (task.status === 'in_progress' && task.claimedBy === sessionId) {
216
+ task.status = 'todo'
217
+ task.updatedAt = this.deps.now()
218
+ delete task.claimedBy
219
+ delete task.claimedAt
220
+ task.comments.push({
221
+ id: newCommentId(),
222
+ body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
223
+ version: 1,
224
+ createdAt: this.deps.now(),
225
+ })
226
+ }
227
+ return [task]
145
228
  }
146
- return [task]
147
229
  }
148
230
  }
149
- }
150
- return undefined
231
+ return undefined
232
+ })
151
233
  })
152
234
  }
153
235
 
@@ -174,9 +256,10 @@ export class ExecutionService {
174
256
  * is opened per task.
175
257
  * @param taskId - the task to run.
176
258
  * @param trigger - what started it.
259
+ * @param options - per-run options (`reuseWorktree` = 续跑).
177
260
  * @returns the immediate result; settlement lands in the ledger.
178
261
  */
179
- async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
262
+ async run(taskId: string, trigger: ExecutionRecord['trigger'], options?: RunOptions): Promise<RunRequestResult> {
180
263
  const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
181
264
  if (this.runs.size >= max) {
182
265
  return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }
@@ -193,6 +276,13 @@ export class ExecutionService {
193
276
  const executionId = newExecutionId()
194
277
  const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
195
278
 
279
+ // 0. Resolve code isolation (plan §3.2): explicit 'none' → zero git calls;
280
+ // 'worktree' (also the omitted default) → prepare below, degrading to
281
+ // the original directory fail-soft on any git problem.
282
+ const isolation: IsolationMode = effectiveIsolation(task)
283
+ const branch = task.branch ?? sanitizeBranchName(task.title, task.id)
284
+ const worktreePath = worktreePathOf(workspace.path, task.id)
285
+
196
286
  // 1. Open the execution record, flip the card to in_progress, and record
197
287
  // the executing session as the claim holder — atomically.
198
288
  let gate: string | undefined
@@ -211,6 +301,7 @@ export class ExecutionService {
211
301
  trigger,
212
302
  startedAt: this.deps.now(),
213
303
  outcome: 'running',
304
+ ...(isolation === 'none' ? { isolation: 'none' as const } : { isolation: 'worktree' as const, branch }),
214
305
  })
215
306
  target.status = 'in_progress'
216
307
  target.updatedAt = this.deps.now()
@@ -221,16 +312,61 @@ export class ExecutionService {
221
312
  })
222
313
  if (gate !== undefined) return { ok: false, error: gate }
223
314
 
315
+ // 1b. Worktree preparation (fail-soft): any failure degrades this run to
316
+ // the original directory with an isolationNote — the ledger and the
317
+ // execution pipeline itself never fail over git.
318
+ let isolationNote: string | undefined
319
+ let prepared: PreparedWorktree | undefined
320
+ if (isolation === 'worktree') {
321
+ const outcome = await this.prepareIsolation(task, workspace.path, worktreePath, branch, options?.reuseWorktree === true)
322
+ if (outcome.prepared !== undefined) {
323
+ prepared = outcome.prepared
324
+ // Persist the isolation facts of the run (branch is already on the
325
+ // record from the gate mutation).
326
+ await this.patchExecution(executionId, {
327
+ worktreePath: outcome.prepared.worktreePath,
328
+ baseCommit: outcome.prepared.baseCommit,
329
+ })
330
+ } else {
331
+ isolationNote = outcome.note
332
+ // Degraded run: clear the optimistic worktree markers.
333
+ await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
334
+ }
335
+ }
336
+
224
337
  // 2. Create the fresh agent+session inside the task's project, carrying
225
338
  // the pinned model — or the deployment default when unpinned (the
226
339
  // persona template renders {{model}}, so the session always needs one).
340
+ // The session cwd is ALWAYS the project root: DSH's session model
341
+ // requires cwd === the workspace path EXACTLY (attachSession validates
342
+ // it, the sidebar groups by it, and the file sandbox takes it as the
343
+ // workspace-write boundary) — a subdirectory cwd (the worktree) breaks
344
+ // all three. The worktree is instead handed to the agent explicitly in
345
+ // the framing line below.
346
+ // Preset composition (0.3.3): resolve BEFORE creation so the header
347
+ // snapshots `agentPreset` and the setup callback mounts the preset's
348
+ // tools/persona into the agent's scope. undefined composeAgent (or an
349
+ // absent preset roster) keeps the bare host composition.
350
+ let composition: AgentComposition | undefined
351
+ try {
352
+ composition = this.deps.composeAgent === undefined ? undefined : await this.deps.composeAgent(task.presetId)
353
+ } catch (error) {
354
+ const message = error instanceof Error ? error.message : String(error)
355
+ await this.patchExecution(executionId, { outcome: 'failed', error: `preset 组合失败:${message.slice(0, 400)}`, endedAt: this.deps.now() })
356
+ await this.revertProgress(taskId)
357
+ return { ok: false, error: `preset composition failed: ${message}` }
358
+ }
227
359
  let handle: Awaited<ReturnType<AgentsFace['create']>>
228
360
  try {
229
361
  const model = task.model ?? this.deps.defaultModel?.()
230
362
  handle = await this.deps.agents.create({
231
363
  sessionId,
232
- meta: { cwd: workspace.path },
364
+ meta: {
365
+ cwd: workspace.path,
366
+ ...(composition !== undefined ? { agentPreset: composition.agentPreset } : {}),
367
+ },
233
368
  ...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),
369
+ ...(composition !== undefined ? { setup: composition.setup } : {}),
234
370
  })
235
371
  } catch (error) {
236
372
  const message = error instanceof Error ? error.message : String(error)
@@ -263,7 +399,7 @@ export class ExecutionService {
263
399
  handle.agent.inject({
264
400
  id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
265
401
  role: 'user' as const,
266
- content: [{ type: 'text' as const, text: this.pluginFraming(task) }],
402
+ content: [{ type: 'text' as const, text: this.pluginFraming(task, prepared, isolationNote) }],
267
403
  source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },
268
404
  })
269
405
  handle.agent.followup({
@@ -274,43 +410,14 @@ export class ExecutionService {
274
410
  })
275
411
 
276
412
  // 6. Settlement watcher: mark succeeded, release the executing session's
277
- // hold, and when the session did NOT follow the handoff protocol
278
- // auto-move the card to in_review with a system comment (otherwise a
279
- // disobedient session would leave it hanging in in_progress forever).
413
+ // hold, collect the worktree evidence (commits / dirty / diff), and
414
+ // when the session did NOT follow the handoff protocol auto-move the
415
+ // card to in_review with a system comment.
280
416
  const settle = (): void => {
281
417
  this.runs.delete(executionId)
282
- void this.deps.store.mutate('execution-recorded', (ledger) => {
283
- for (const t of ledger.tasks) {
284
- const execution = t.executions.find(e => e.id === executionId)
285
- if (execution !== undefined && execution.outcome === 'running') {
286
- const now = this.deps.now()
287
- execution.outcome = 'succeeded'
288
- execution.endedAt = now
289
- if (t.status === 'in_progress' && t.claimedBy === sessionId) {
290
- delete t.claimedBy
291
- delete t.claimedAt
292
- }
293
- if (t.status === 'in_progress') {
294
- const commented = t.comments.some(c => c.threadId === sessionId)
295
- t.comments.push({
296
- id: newCommentId(),
297
- body: normalizeBody(commented
298
- ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
299
- : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
300
- version: 1,
301
- createdAt: now,
302
- })
303
- t.status = 'in_review'
304
- t.updatedAt = now
305
- t.updatedBy = { kind: 'user' }
306
- }
307
- return [t]
308
- }
309
- }
310
- return undefined
311
- })
418
+ void this.settleExecution(executionId, sessionId, prepared)
312
419
  }
313
- this.runs.set(executionId, { sessionId, settle, dispose: () => handle.dispose() })
420
+ this.runs.set(executionId, { sessionId, ...(prepared !== undefined ? { prepared } : {}), settle, dispose: () => handle.dispose() })
314
421
  void handle.agent.whenIdle().then(settle, () => {
315
422
  this.noteFailure(sessionId, 'agent did not reach quiescence')
316
423
  settle()
@@ -319,6 +426,105 @@ export class ExecutionService {
319
426
  return { ok: true, executionId, sessionId }
320
427
  }
321
428
 
429
+ /**
430
+ * Settle one execution: collect worktree facts first (fail-soft — git
431
+ * problems never block settlement), then commit outcome + release + the
432
+ * protocol-auto-review move in ONE ledger mutation.
433
+ */
434
+ private async settleExecution(
435
+ executionId: string,
436
+ sessionId: string,
437
+ prepared: PreparedWorktree | undefined,
438
+ ): Promise<void> {
439
+ const facts = await this.collectEvidence(prepared)
440
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
441
+ for (const t of ledger.tasks) {
442
+ const execution = t.executions.find(e => e.id === executionId)
443
+ if (execution !== undefined && execution.outcome === 'running') {
444
+ const now = this.deps.now()
445
+ execution.outcome = 'succeeded'
446
+ execution.endedAt = now
447
+ this.applyFacts(execution, facts)
448
+ if (t.status === 'in_progress' && t.claimedBy === sessionId) {
449
+ delete t.claimedBy
450
+ delete t.claimedAt
451
+ }
452
+ if (t.status === 'in_progress') {
453
+ const commented = t.comments.some(c => c.threadId === sessionId)
454
+ t.comments.push({
455
+ id: newCommentId(),
456
+ body: normalizeBody(commented
457
+ ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
458
+ : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
459
+ version: 1,
460
+ createdAt: now,
461
+ })
462
+ t.status = 'in_review'
463
+ t.updatedAt = now
464
+ t.updatedBy = { kind: 'user' }
465
+ }
466
+ return [t]
467
+ }
468
+ }
469
+ return undefined
470
+ })
471
+ }
472
+
473
+ /**
474
+ * Prepare the dedicated worktree for a run (fail-soft): detect git, then
475
+ * create/reset the fixed task branch — fresh baseline, or keep a live
476
+ * worktree as-is for 续跑. On success the branch name is pinned onto the
477
+ * task once (renames never change it); on any failure the run degrades
478
+ * with a human-readable note.
479
+ */
480
+ private async prepareIsolation(
481
+ task: TaskRecord,
482
+ workspacePath: string,
483
+ worktreePath: string,
484
+ branch: string,
485
+ reuse: boolean,
486
+ ): Promise<{ prepared?: PreparedWorktree; note?: string }> {
487
+ const git = this.deps.git
488
+ if (git === undefined) return { note: 'git 集成不可用,已在原目录执行' }
489
+ let inside = false
490
+ try {
491
+ inside = await git.detect(workspacePath)
492
+ } catch { /* fail-soft */ }
493
+ if (!inside) {
494
+ // Distinguish 未装 git from 非 git 仓库 (0.3.1): probe the binary so the
495
+ // degradation note names the real cause.
496
+ let hasBinary = true
497
+ try {
498
+ hasBinary = await git.binaryAvailable()
499
+ } catch { /* fail-soft → treat as repo-side */ }
500
+ return { note: hasBinary ? '当前项目不是 git 仓库,已在原目录执行' : 'git 不可用(未安装或不在 PATH),已在原目录执行' }
501
+ }
502
+ let info
503
+ try {
504
+ info = await git.prepareWorktree(workspacePath, worktreePath, branch, reuse ? 'reuse' : 'fresh')
505
+ } catch { /* fail-soft */ }
506
+ if (info === undefined) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }
507
+ // Pin the branch name at first SUCCESSFUL creation (§9: 改名不改分支).
508
+ if (task.branch === undefined) {
509
+ await this.deps.store.mutate('task-updated', (ledger) => {
510
+ const target = ledger.tasks.find(t => t.id === task.id)
511
+ if (target !== undefined && target.branch === undefined) {
512
+ target.branch = branch
513
+ return [target]
514
+ }
515
+ return undefined
516
+ })
517
+ }
518
+ return {
519
+ prepared: {
520
+ branch: info.branch,
521
+ worktreePath: info.path,
522
+ baseCommit: info.baseCommit,
523
+ ...(info.reused === true ? { reused: true } : {}),
524
+ },
525
+ }
526
+ }
527
+
322
528
  /** How many executions are currently running (for the concurrency cap). */
323
529
  inFlight(): number {
324
530
  return this.runs.size
@@ -345,6 +551,9 @@ export class ExecutionService {
345
551
  await entry?.dispose()
346
552
  } catch { /* already gone */ }
347
553
 
554
+ // The cancelled session may already have committed work — keep the
555
+ // evidence (best effort) so the user can inspect or 续跑 (0.3.1).
556
+ const facts = await this.collectEvidence(entry?.prepared)
348
557
  await this.deps.store.mutate('execution-recorded', (ledger) => {
349
558
  const target = ledger.tasks.find(t => t.id === taskId)
350
559
  if (target === undefined) return undefined
@@ -352,6 +561,7 @@ export class ExecutionService {
352
561
  if (execution === undefined || execution.outcome !== 'running') return undefined
353
562
  execution.outcome = 'cancelled'
354
563
  execution.endedAt = this.deps.now()
564
+ this.applyFacts(execution, facts)
355
565
  if (target.status === 'in_progress') {
356
566
  target.status = 'todo'
357
567
  target.updatedAt = this.deps.now()
@@ -399,16 +609,32 @@ export class ExecutionService {
399
609
  * The plugin framing line (rendered as a plugin context row): task head,
400
610
  * already-claimed state, and the handoff protocol — everything the session
401
611
  * must know about the board. The task id appears exactly once (here); the
402
- * protocol steps below refer to it as 本任务.
612
+ * protocol steps below refer to it as 本任务. Isolated runs add one line
613
+ * steering the session onto its dedicated branch (commits are the evidence
614
+ * the user reviews at merge time); 续跑 and degraded runs each add their
615
+ * own steering line (0.3.1).
616
+ * @param task - the task.
617
+ * @param prepared - worktree facts when this run is isolated.
618
+ * @param degradeNote - why a worktree task degraded to the main directory.
403
619
  */
404
- private pluginFraming(task: TaskRecord): string {
405
- return `【任务看板】${task.title}(ID: ${task.id})\n`
620
+ private pluginFraming(task: TaskRecord, prepared?: PreparedWorktree, degradeNote?: string): string {
621
+ let text = `【任务看板】${task.title}(ID: ${task.id})\n`
406
622
  + `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
407
623
  + `完成后按序交接:\n`
408
624
  + `1. taskboard_get 读取本任务,取得最新 version\n`
409
625
  + `2. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\n`
410
626
  + `3. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\n`
411
627
  + `若无法完成:留评论说明原因,将任务移回待办 todo。`
628
+ if (prepared !== undefined) {
629
+ if (prepared.reused === true) {
630
+ text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\n${prepared.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`
631
+ } else {
632
+ text += `\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${prepared.branch} 的全新 worktree——\n${prepared.worktreePath}\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`
633
+ }
634
+ } else if (degradeNote !== undefined) {
635
+ text += `\n⚠ 本次执行未能建立隔离,正在主项目目录中工作(原因:${degradeNote})。该目录可能有他人未提交的改动:动手前先 git status 检查现状,改动尽量集中,结束时在评论中说明动了哪些文件;避免把未经验证的改动直接提交到主分支。`
636
+ }
637
+ return text
412
638
  }
413
639
 
414
640
  /**