gsd-lite 0.3.5 → 0.3.6

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.
@@ -13,7 +13,7 @@
13
13
  "name": "gsd",
14
14
  "source": "./",
15
15
  "description": "AI orchestration tool — GSD management shell + Superpowers quality core. 5 commands, 4 agents, 5 workflows, MCP server, context monitoring.",
16
- "version": "0.3.4",
16
+ "version": "0.3.6",
17
17
  "keywords": ["orchestration", "mcp", "tdd", "task-management"],
18
18
  "category": "Development workflows"
19
19
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "author": {
6
6
  "name": "sdsrss",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: debugger
3
3
  description: Systematic debugging with root cause analysis
4
- tools: Read, Write, Edit, Bash, Grep, Glob
4
+ tools: Read, Bash, Grep, Glob
5
5
  ---
6
6
 
7
7
  <role>
@@ -54,11 +54,11 @@ Phase 3 假设测试:
54
54
  2. 最小变更测试 (一次只改一个变量)
55
55
  3. 验证: 有效 → Phase 4 / 无效 → 新假设
56
56
 
57
- Phase 4 实施修复:
58
- 1. 写失败测试 (复现 bug)
59
- 2. 修复根因 (不是症状)
60
- 3. 验证测试通过 + 无回归
61
- → 3 次修复失败停止。质疑架构。报告给编排器。
57
+ Phase 4 修复方向建议:
58
+ 1. 提出修复方案 (针对根因,不是症状)
59
+ 2. 建议失败测试用例 (供 executor 实现)
60
+ 3. 评估修复影响范围 (哪些下游可能受影响)
61
+ → 3 次修复方向均被 executor 验证无效 停止。标记 architecture_concern: true。报告给编排器。
62
62
  </four_phases>
63
63
 
64
64
  <result_contract>
@@ -39,3 +39,12 @@ tools: Read, Write, Bash, WebSearch, WebFetch, mcp__plugin_context7_context7__*
39
39
  ```
40
40
  </result_contract>
41
41
  </research_output>
42
+
43
+ <uncertainty_handling>
44
+ ## 遇到不确定性时
45
+ 子代理不能直接与用户交互。遇到不确定性时:
46
+ 1. 来源冲突 → 报告双方立场及置信度,让编排器决定。在 result 中标注 "[DECISION] 选择了X因为Y"
47
+ 2. 所有来源不可用 (Context7 + WebSearch + 官方文档均失败) → 返回 "[BLOCKED] 需要: 研究来源不可用,请提供替代信息或缩小范围"
48
+ 3. 研究范围过广无法收敛 → 返回 "[BLOCKED] 需要: 研究范围过广,请指定重点领域"
49
+ 4. 发现结论与已有 decisions 矛盾 → 在 result 中标注冲突,让编排器决定是否更新 decision
50
+ </uncertainty_handling>
package/commands/prd.md CHANGED
@@ -100,12 +100,16 @@ argument-hint: File path to requirements doc, or inline description text
100
100
 
101
101
  → 自审修正后再展示给用户
102
102
 
103
+ <HARD-GATE id="plan-confirmation">
103
104
  ## STEP 9: 展示计划,等待用户确认
104
105
 
105
106
  - 展示完整分阶段计划
106
107
  - 用户指出问题 → 调整 → 再展示
107
108
  - 用户确认 → 继续
108
109
 
110
+ ⛔ 不得在用户确认前执行 STEP 10-12。未确认 = 不写文件、不执行代码。
111
+ </HARD-GATE>
112
+
109
113
  ## STEP 10: 生成文档
110
114
 
111
115
  - 创建 .gsd/ 目录
@@ -35,17 +35,18 @@ description: Resume project execution from saved state with workspace validation
35
35
  - 不一致 → 覆写 `workflow_mode = reconcile_workspace`
36
36
 
37
37
  2. **计划版本校验:**
38
- - 如果本地 plan.md 或 phases/*.md 被手动修改,且 `plan_version` 不匹配
38
+ - 如果本地 plan.md 或 phases/*.md 被手动修改 (mtime > last_session)
39
39
  - → 覆写 `workflow_mode = replan_required`
40
40
 
41
- 3. **研究过期校验:**
41
+ 3. **方向漂移校验:**
42
+ - 如果当前或任何未完成 phase 的 `phase_handoff.direction_ok === false`
43
+ - → 覆写 `workflow_mode = awaiting_user`
44
+
45
+ 4. **研究过期校验:**
42
46
  - 如果 `research.expires_at` 已过期 (早于当前时间)
47
+ - 或 research.decision_index 中有条目的 expires_at 已过期
43
48
  - → 覆写 `workflow_mode = research_refresh_needed`
44
49
 
45
- 4. **工作区冲突校验:**
46
- - 运行 `git status` 检查是否存在冲突或脏工作区
47
- - 存在未解决的合并冲突 → 覆写 `workflow_mode = awaiting_user`
48
-
49
50
  5. **全部通过:**
50
51
  - 保持原 `workflow_mode` 不变
51
52
 
@@ -176,6 +177,14 @@ description: Resume project execution from saved state with workspace validation
176
177
 
177
178
  ---
178
179
 
180
+ ### `planning` — 计划中断
181
+
182
+ - 计划编制过程中被中断
183
+ - 告知用户: "项目仍在计划阶段。请运行 /gsd:start 或 /gsd:prd 重新启动计划流程"
184
+ - 不自动执行
185
+
186
+ ---
187
+
179
188
  ### `failed` — 已失败
180
189
 
181
190
  - 展示失败信息:
package/commands/start.md CHANGED
@@ -30,7 +30,7 @@ argument-hint: Optional feature or project description
30
30
  ## STEP 4 — 需求追问
31
31
 
32
32
  用户回答后,跟进追问直到需求清晰:
33
- - 使用 `references/questioning.md` 技巧 (挑战模糊、具象化、发现边界)
33
+ - 使用 Read 工具读取 `references/questioning.md`,按其中的技巧进行提问 (挑战模糊、具象化、发现边界)
34
34
  - 每个问题提供选项,标识 ⭐ 推荐选项
35
35
  - 多轮对话直到需求清晰 (通常 2-4 轮)
36
36
  - 每轮最多 3-5 个问题,避免过度追问
@@ -113,12 +113,17 @@ argument-hint: Optional feature or project description
113
113
 
114
114
  → 自审修正后再展示给用户。
115
115
 
116
+ <HARD-GATE id="plan-confirmation">
116
117
  ## STEP 9 — 用户确认计划
117
118
 
118
119
  展示计划给用户,等待确认:
119
120
  - 用户指出问题 → 调整计划 → 重新展示
120
121
  - 用户确认 → 继续
121
122
 
123
+ ⛔ 不得在用户确认前执行 STEP 10-12。未确认 = 不写文件、不执行代码。
124
+ </HARD-GATE>
125
+
126
+ <HARD-GATE id="docs-written">
122
127
  ## STEP 10 — 生成文档
123
128
 
124
129
  1. 创建 `.gsd/` 目录
@@ -141,6 +146,13 @@ argument-hint: Optional feature or project description
141
146
  - `phases/*.md` 是 task 规格的唯一 source of truth
142
147
  - `plan.md` 不包含 task 级细节,避免与 `phases/*.md` 重复
143
148
 
149
+ □ state.json 已写入且包含所有 canonical fields
150
+ □ plan.md 已写入
151
+ □ phases/*.md 已写入 (每个 phase 一个文件)
152
+ □ 所有 task 都有 lifecycle / level / requires / review_required
153
+ → 全部满足才可继续
154
+ </HARD-GATE>
155
+
144
156
  ## STEP 11 — 自动执行主路径
145
157
 
146
158
  进入执行主循环。phase = 管理边界,task = 执行边界。
package/commands/stop.md CHANGED
@@ -11,8 +11,9 @@ description: Save current state and pause project execution
11
11
 
12
12
  ## STEP 1: 保存完整状态
13
13
 
14
- 读取并更新 `.gsd/state.json`:
14
+ 读取 `.gsd/state.json`:
15
15
  - 如果文件不存在 → 告知用户 "未找到 GSD 项目状态,无需停止",停止
16
+ - 如果 `workflow_mode` 已是 `completed` 或 `failed` → 告知用户 "项目已终结 ({workflow_mode}),无需停止",停止
16
17
 
17
18
  确保以下信息已保存到 state.json:
18
19
  - `current_phase` / `current_task` — 当前执行位置
@@ -31,10 +31,10 @@ export function postToolUse(basePath) {
31
31
  const gsdDir = join(basePath || process.cwd(), '.gsd');
32
32
  const health = parseInt(readFileSync(join(gsdDir, '.context-health'), 'utf-8'), 10);
33
33
 
34
- if (health < 25) {
34
+ if (health <= 25) {
35
35
  return `🛑 CONTEXT EMERGENCY (${health}% remaining): Save state NOW. Set workflow_mode = awaiting_clear. Tell user to /clear then /gsd:resume.`;
36
36
  }
37
- if (health < 35) {
37
+ if (health <= 35) {
38
38
  return `⚠️ CONTEXT LOW (${health}% remaining): Complete current task, save state, set workflow_mode = awaiting_clear. Tell user to /clear then /gsd:resume.`;
39
39
  }
40
40
  } catch (err) {
@@ -68,14 +68,18 @@ process.stdin.on('end', () => {
68
68
  process.exit(0);
69
69
  }
70
70
 
71
+ // Non-GSD sessions: don't interfere — let Claude's auto-compaction handle it
72
+ const isGsdActive = metrics.has_gsd === true;
73
+ if (!isGsdActive) {
74
+ process.exit(0);
75
+ }
76
+
71
77
  // Debounce logic
72
78
  const warnPath = path.join(tmpDir, `gsd-ctx-${sessionId}-warned.json`);
73
79
  let warnData = { callsSinceWarn: 0, lastLevel: null };
74
- let firstWarn = true;
75
80
 
76
81
  try {
77
82
  warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
78
- firstWarn = false;
79
83
  } catch {
80
84
  // No prior warning state — first warning this session
81
85
  }
@@ -85,25 +89,24 @@ process.stdin.on('end', () => {
85
89
  const isCritical = remaining <= CRITICAL_THRESHOLD;
86
90
  const currentLevel = isCritical ? 'critical' : 'warning';
87
91
 
88
- // Severity escalation bypasses debounce
92
+ // Atomic debounce state write helper
93
+ const writeWarnData = (data) => {
94
+ const tmpFile = warnPath + `.${process.pid}-${Date.now()}.tmp`;
95
+ fs.writeFileSync(tmpFile, JSON.stringify(data));
96
+ fs.renameSync(tmpFile, warnPath);
97
+ };
98
+
99
+ // Severity escalation bypasses debounce (lastLevel null = first warning, always fire)
89
100
  const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning';
90
- if (!firstWarn && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
91
- fs.writeFileSync(warnPath, JSON.stringify(warnData));
101
+ if (warnData.lastLevel !== null && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
102
+ writeWarnData(warnData);
92
103
  process.exit(0);
93
104
  }
94
105
 
95
106
  // Reset debounce
96
107
  warnData.callsSinceWarn = 0;
97
108
  warnData.lastLevel = currentLevel;
98
- fs.writeFileSync(warnPath, JSON.stringify(warnData));
99
-
100
- // Use bridge data to avoid extra filesystem check
101
- const isGsdActive = metrics.has_gsd === true;
102
-
103
- // Non-GSD sessions: don't interfere — let Claude's auto-compaction handle it
104
- if (!isGsdActive) {
105
- process.exit(0);
106
- }
109
+ writeWarnData(warnData);
107
110
 
108
111
  let message;
109
112
  if (isCritical) {
@@ -7,6 +7,25 @@ const fs = require('node:fs');
7
7
  const path = require('node:path');
8
8
  const os = require('node:os');
9
9
 
10
+ /**
11
+ * Walk from startDir up to filesystem root looking for a .gsd directory.
12
+ * Returns the absolute path to .gsd if found, or null.
13
+ */
14
+ function findGsdDir(startDir) {
15
+ let dir = startDir;
16
+ while (true) {
17
+ const candidate = path.join(dir, '.gsd');
18
+ try {
19
+ fs.statSync(candidate);
20
+ return candidate;
21
+ } catch {
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) return null; // reached filesystem root
24
+ dir = parent;
25
+ }
26
+ }
27
+ }
28
+
10
29
  let input = '';
11
30
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
12
31
  process.stdin.setEncoding('utf8');
@@ -24,7 +43,7 @@ process.stdin.on('end', () => {
24
43
  // Current GSD task from state.json
25
44
  let task = '';
26
45
  let hasGsd = false;
27
- const gsdDir = path.join(cwd, '.gsd');
46
+ const gsdDir = findGsdDir(cwd);
28
47
  try {
29
48
  const state = JSON.parse(fs.readFileSync(path.join(gsdDir, 'state.json'), 'utf8'));
30
49
  hasGsd = true;
@@ -71,21 +90,24 @@ process.stdin.on('end', () => {
71
90
  }
72
91
 
73
92
  // Also write to .gsd/.context-health for MCP server reads (atomic, skip if unchanged)
74
- try {
75
- const healthPath = path.join(gsdDir, '.context-health');
76
- let needsHealthWrite = true;
93
+ // Only write if a .gsd directory was found — never create .gsd from the hook
94
+ if (gsdDir) {
77
95
  try {
78
- const current = fs.readFileSync(healthPath, 'utf8').trim();
79
- if (current === String(remaining)) needsHealthWrite = false;
80
- } catch { /* file doesn't exist yet */ }
81
- if (needsHealthWrite) {
82
- fs.mkdirSync(gsdDir, { recursive: true });
83
- const tmpHealth = path.join(gsdDir, `.context-health.${process.pid}-${Date.now()}.tmp`);
84
- fs.writeFileSync(tmpHealth, String(remaining));
85
- fs.renameSync(tmpHealth, healthPath);
96
+ const healthPath = path.join(gsdDir, '.context-health');
97
+ let needsHealthWrite = true;
98
+ try {
99
+ const current = fs.readFileSync(healthPath, 'utf8').trim();
100
+ if (current === String(remaining)) needsHealthWrite = false;
101
+ } catch { /* file doesn't exist yet */ }
102
+ if (needsHealthWrite) {
103
+ fs.mkdirSync(gsdDir, { recursive: true });
104
+ const tmpHealth = path.join(gsdDir, `.context-health.${process.pid}-${Date.now()}.tmp`);
105
+ fs.writeFileSync(tmpHealth, String(remaining));
106
+ fs.renameSync(tmpHealth, healthPath);
107
+ }
108
+ } catch (e) {
109
+ if (process.env.GSD_DEBUG) process.stderr.write(`gsd-statusline: context-health write failed: ${e.message}\n`);
86
110
  }
87
- } catch (e) {
88
- if (process.env.GSD_DEBUG) process.stderr.write(`gsd-statusline: context-health write failed: ${e.message}\n`);
89
111
  }
90
112
 
91
113
  // Progress bar (10 segments)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {
@@ -102,13 +102,12 @@ if (Object.keys(state.evidence).length > MAX_EVIDENCE_ENTRIES) {
102
102
 
103
103
  `_pruneEvidenceFromState(state, currentPhase, gsdDir)`:
104
104
 
105
- 1. 计算阈值: `threshold = currentPhase - 1`
106
- 2. 遍历所有 evidence 条目
107
- 3. 对每条 evidence 调用 `parseScopePhase(entry.scope)` 提取 phase 编号
108
- 4. 如果 `phaseNum !== null && phaseNum < threshold` -> 标记为待归档
109
- 5. 其余保留 (包括 scope 无法解析的条目)
105
+ 1. 遍历所有 evidence 条目
106
+ 2. 对每条 evidence 调用 `parseScopePhase(entry.scope)` 提取 phase 编号
107
+ 3. 如果 `phaseNum !== null && phaseNum < currentPhase` -> 标记为待归档
108
+ 4. 其余保留 (包括 scope 无法解析的条目)
110
109
 
111
- 规则: 保留当前 phase 和前一个 phase 的 evidence,归档更早 phase 的 evidence。
110
+ 规则: 仅保留当前 phase 的 evidence,归档所有更早 phase 的 evidence。
112
111
 
113
112
  ## 归档生命周期
114
113
 
@@ -123,7 +123,7 @@ executor 上下文传递协议 (orchestrator → executor):
123
123
  阶段完成后,编排器批量更新 state.json:
124
124
  - 更新 phase lifecycle → `accepted`
125
125
  - 更新 phase_handoff 信息
126
- - 归档旧 phase 的 evidence (只保留当前 phase 和上一 phase)
126
+ - 归档旧 phase 的 evidence (仅保留当前 phase)
127
127
  - 推进 `current_phase` 到下一个 pending phase
128
128
 
129
129
  **规则:** 只有编排器写 state.json,避免并发竞态。
@@ -133,13 +133,13 @@ executor 上下文传递协议 (orchestrator → executor):
133
133
  每次派发子代理前和阶段切换时检查上下文健康度:
134
134
 
135
135
  ```
136
- remaining < 35%:
136
+ remaining <= 35%:
137
137
  1. 保存完整状态到 state.json
138
138
  2. workflow_mode = awaiting_clear
139
- 3. 输出: "上下文剩余 <35%,已保存进度。请执行 /clear 然后 /gsd:resume 继续"
139
+ 3. 输出: "上下文剩余 <=35%,已保存进度。请执行 /clear 然后 /gsd:resume 继续"
140
140
  4. 停止执行
141
141
 
142
- remaining < 25%:
142
+ remaining <= 25%:
143
143
  1. 紧急保存状态到 state.json
144
144
  2. workflow_mode = awaiting_clear
145
145
  3. 输出: "上下文即将耗尽,已保存进度。请立即执行 /clear 然后 /gsd:resume"
package/src/schema.js CHANGED
@@ -36,6 +36,8 @@ export const PHASE_LIFECYCLE = {
36
36
  failed: [],
37
37
  };
38
38
 
39
+ export const TASK_LEVELS = ['L0', 'L1', 'L2', 'L3'];
40
+
39
41
  export const PHASE_REVIEW_STATUS = ['pending', 'reviewing', 'accepted', 'rework_required'];
40
42
 
41
43
  export const CANONICAL_FIELDS = [
@@ -387,8 +389,8 @@ export function validateState(state) {
387
389
  if (!TASK_LIFECYCLE[task.lifecycle]) {
388
390
  errors.push(`Task ${task.id}: invalid lifecycle ${task.lifecycle}`);
389
391
  }
390
- if (typeof task.level !== 'string') {
391
- errors.push(`Task ${task.id}: level must be a string`);
392
+ if (!TASK_LEVELS.includes(task.level)) {
393
+ errors.push(`Task ${task.id}: level must be one of ${TASK_LEVELS.join(', ')}`);
392
394
  }
393
395
  if (!Array.isArray(task.requires)) {
394
396
  errors.push(`Task ${task.id}: requires must be an array`);
@@ -15,6 +15,7 @@ import { validateDebuggerResult, validateExecutorResult, validateResearcherResul
15
15
  import { getGitHead, getGsdDir } from '../utils.js';
16
16
 
17
17
  const MAX_DEBUG_RETRY = 3;
18
+ const MAX_RESUME_DEPTH = 3;
18
19
  const CONTEXT_RESUME_THRESHOLD = 40;
19
20
 
20
21
  function isTerminalWorkflowMode(workflowMode) {
@@ -482,7 +483,11 @@ async function resumeExecutingTask(state, basePath) {
482
483
  };
483
484
  }
484
485
 
485
- export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
486
+ export async function resumeWorkflow({ basePath = process.cwd(), _depth = 0 } = {}) {
487
+ if (_depth >= MAX_RESUME_DEPTH) {
488
+ return { error: true, message: `resumeWorkflow recursive depth limit exceeded (max ${MAX_RESUME_DEPTH})` };
489
+ }
490
+
486
491
  const state = await read({ basePath });
487
492
  if (state.error) {
488
493
  return state;
@@ -540,7 +545,7 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
540
545
  current_review: null,
541
546
  });
542
547
  if (persistError) return persistError;
543
- const resumed = await resumeWorkflow({ basePath });
548
+ const resumed = await resumeWorkflow({ basePath, _depth: _depth + 1 });
544
549
  if (resumed.error) return resumed;
545
550
  return { ...resumed, auto_unblocked: autoUnblock.autoUnblocked };
546
551
  }
@@ -614,17 +619,48 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
614
619
  total_phases: state.total_phases,
615
620
  message: 'Workflow already completed',
616
621
  };
617
- case 'failed':
622
+ case 'failed': {
623
+ const failedPhases = [];
624
+ const failedTasks = [];
625
+ for (const phase of state.phases || []) {
626
+ if (phase.lifecycle === 'failed') failedPhases.push({ id: phase.id, name: phase.name });
627
+ for (const t of phase.todo || []) {
628
+ if (t.lifecycle === 'failed') {
629
+ failedTasks.push({
630
+ id: t.id,
631
+ name: t.name,
632
+ phase_id: phase.id,
633
+ retry_count: t.retry_count || 0,
634
+ last_failure_summary: t.last_failure_summary || null,
635
+ debug_context: t.debug_context || null,
636
+ });
637
+ }
638
+ }
639
+ }
618
640
  return {
619
641
  success: true,
620
- action: 'noop',
642
+ action: 'await_recovery_decision',
621
643
  workflow_mode: state.workflow_mode,
622
- failed_phases: (state.phases || []).filter((phase) => phase.lifecycle === 'failed').map((phase) => phase.id),
623
- failed_tasks: (state.phases || []).flatMap((phase) =>
624
- (phase.todo || []).filter((task) => task.lifecycle === 'failed').map((task) => task.id)),
625
- message: 'Workflow is in failed state',
644
+ failed_phases: failedPhases,
645
+ failed_tasks: failedTasks,
646
+ recovery_options: ['retry_failed', 'skip_failed', 'replan'],
647
+ message: 'Workflow is in failed state. Recovery options available.',
626
648
  };
649
+ }
627
650
  case 'paused_by_user':
651
+ return {
652
+ success: true,
653
+ action: 'await_manual_intervention',
654
+ workflow_mode: state.workflow_mode,
655
+ resume_to: state.current_review?.scope === 'phase'
656
+ ? 'reviewing_phase'
657
+ : state.current_review?.scope === 'task'
658
+ ? 'reviewing_task'
659
+ : 'executing_task',
660
+ current_review: state.current_review || null,
661
+ current_task: state.current_task || null,
662
+ message: 'Project is paused. Confirm to resume execution.',
663
+ };
628
664
  case 'planning':
629
665
  case 'reconcile_workspace':
630
666
  case 'replan_required':
@@ -160,6 +160,15 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
160
160
  }
161
161
  const state = result.data;
162
162
 
163
+ // Guard: reject workflow_mode changes FROM terminal states
164
+ if (updates.workflow_mode) {
165
+ const currentMode = state.workflow_mode;
166
+ if ((currentMode === 'completed' || currentMode === 'failed')
167
+ && updates.workflow_mode !== currentMode) {
168
+ return { error: true, message: `Cannot change workflow_mode from terminal state '${currentMode}'` };
169
+ }
170
+ }
171
+
163
172
  // Validate lifecycle transitions before merging
164
173
  if (updates.phases && Array.isArray(updates.phases)) {
165
174
  for (const newPhase of updates.phases) {
@@ -388,6 +397,9 @@ export async function phaseComplete({
388
397
  if (nextPhase && nextPhase.lifecycle === 'pending') {
389
398
  nextPhase.lifecycle = 'active';
390
399
  }
400
+ } else if (state.current_phase === phase_id && phase_id >= state.total_phases) {
401
+ // Final phase completed — mark workflow as completed
402
+ state.workflow_mode = 'completed';
391
403
  }
392
404
 
393
405
  // Update git_head to current commit
@@ -454,13 +466,12 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
454
466
  async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
455
467
  if (!state.evidence) return 0;
456
468
 
457
- const threshold = currentPhase - 1;
458
469
  const toArchive = {};
459
470
  const toKeep = {};
460
471
 
461
472
  for (const [id, entry] of Object.entries(state.evidence)) {
462
473
  const phaseNum = parseScopePhase(entry.scope);
463
- if (phaseNum !== null && phaseNum < threshold) {
474
+ if (phaseNum !== null && phaseNum < currentPhase) {
464
475
  toArchive[id] = entry;
465
476
  } else {
466
477
  toKeep[id] = entry;
@@ -483,7 +494,7 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
483
494
  }
484
495
 
485
496
  /**
486
- * Prune evidence: archive entries from phases older than currentPhase - 1.
497
+ * Prune evidence: archive entries from phases before currentPhase (keep only current phase).
487
498
  * Scope format is "task:X.Y" where X is the phase number.
488
499
  */
489
500
  export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
@@ -930,11 +941,10 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
930
941
  throw err;
931
942
  }
932
943
 
933
- const { decision_index: _, ...nextResearchBase } = {
944
+ const nextResearchBase = {
934
945
  volatility: result.volatility,
935
946
  expires_at: result.expires_at,
936
947
  sources: result.sources,
937
- decision_index,
938
948
  files: RESEARCH_FILES,
939
949
  updated_at: new Date().toISOString(),
940
950
  };