gsd-lite 0.6.7 → 0.6.8

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.6.7",
16
+ "version": "0.6.8",
17
17
  "keywords": [
18
18
  "orchestration",
19
19
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "author": {
6
6
  "name": "sdsrss",
@@ -56,6 +56,7 @@ tools: Read, Write, Edit, Bash, Grep, Glob
56
56
  "blockers": [],
57
57
  "contract_changed": true,
58
58
  "confidence": "high",
59
+ "error_fingerprint": "optional string — short fingerprint for 3-strike deduplication (file+line or msg[:50])",
59
60
  "evidence": [
60
61
  {"id": "ev:test:users-update", "scope": "task:2.3"},
61
62
  {"id": "ev:typecheck:phase-2", "scope": "task:2.3"}
@@ -68,7 +68,30 @@ tools: Read, Write, Bash, WebSearch, WebFetch, mcp__plugin_context7_context7__*
68
68
  ## 遇到不确定性时
69
69
  子代理不能直接与用户交互。遇到不确定性时:
70
70
  1. 来源冲突 → 报告双方立场及置信度,让编排器决定。在 result 中标注 "[DECISION] 选择了X因为Y"
71
- 2. 所有来源不可用 (Context7 + WebSearch + 官方文档均失败) → 返回 "[BLOCKED] 需要: 研究来源不可用,请提供替代信息或缩小范围"
72
- 3. 研究范围过广无法收敛 → 返回 "[BLOCKED] 需要: 研究范围过广,请指定重点领域"
71
+ 2. 所有来源不可用 (Context7 + WebSearch + 官方文档均失败) → 仍然返回有效的 result contract JSON (编排器需要通过 `validateResearcherResult` 校验),在 decision 摘要中标注阻塞原因:
72
+ ```json
73
+ {
74
+ "result": {
75
+ "decision_ids": ["decision:blocked-no-sources"],
76
+ "volatility": "high",
77
+ "expires_at": "<24h后的ISO时间>",
78
+ "sources": []
79
+ },
80
+ "decision_index": {
81
+ "decision:blocked-no-sources": {
82
+ "summary": "[BLOCKED] 研究来源不可用,请提供替代信息或缩小范围",
83
+ "source": "none",
84
+ "expires_at": "<24h后的ISO时间>"
85
+ }
86
+ },
87
+ "artifacts": {
88
+ "STACK.md": "# 研究受阻\n来源不可用,无法完成研究。",
89
+ "ARCHITECTURE.md": "# 研究受阻\n来源不可用。",
90
+ "PITFALLS.md": "# 研究受阻\n来源不可用。",
91
+ "SUMMARY.md": "# 研究受阻\n所有来源 (Context7/WebSearch/官方文档) 均不可用。需要用户提供替代信息或缩小范围。"
92
+ }
93
+ }
94
+ ```
95
+ 3. 研究范围过广无法收敛 → 同上模式,decision 摘要改为 "[BLOCKED] 研究范围过广,请指定重点领域"
73
96
  4. 发现结论与已有 decisions 矛盾 → 在 result 中标注冲突,让编排器决定是否更新 decision
74
97
  </uncertainty_handling>
@@ -44,7 +44,7 @@ Also verify the hook files exist on disk:
44
44
 
45
45
  ## STEP 4: Lock File Check
46
46
 
47
- Check if `.gsd/.state-lock` exists:
47
+ Check if `.gsd/state.lock` exists:
48
48
  - If not exists: record PASS "No stale lock"
49
49
  - If exists: check file age
50
50
  - Older than 5 minutes: record WARN "Stale lock file detected (age: {age}). May indicate a crashed process. Consider removing it."
@@ -51,16 +51,16 @@ description: Resume project execution from saved state with workspace validation
51
51
  - 如果当前或任何未完成 phase 的 `phase_handoff.direction_ok === false`
52
52
  - → 覆写 `workflow_mode = awaiting_user`
53
53
 
54
- 4. **研究过期校验:**
54
+ 4. **Dirty-phase 回滚检测:**
55
+ - 检查 `current_phase` 之前的 phase (`p.id < current_phase`) 中是否有 `needs_revalidation` 状态的 task
56
+ - 如有 → 回滚 `current_phase` 到最早的 dirty phase
57
+ - → 覆写 `workflow_mode = executing_task`
58
+
59
+ 5. **研究过期校验:**
55
60
  - 如果 `research.expires_at` 已过期 (早于当前时间)
56
61
  - 或 research.decision_index 中有条目的 expires_at 已过期
57
62
  - → 覆写 `workflow_mode = research_refresh_needed`
58
63
 
59
- 5. **Dirty-phase 回滚检测:**
60
- - 检查已完成 phase 中是否有 `needs_revalidation` 状态的 task
61
- - 如有 → 回滚 `current_phase` 到最早的 dirty phase
62
- - → 覆写 `workflow_mode = executing_task`
63
-
64
64
  6. **全部通过:**
65
65
  - 保持原 `workflow_mode` 不变
66
66
 
package/commands/stop.md CHANGED
@@ -27,6 +27,8 @@ description: Save current state and pause project execution
27
27
 
28
28
  将 `workflow_mode` 设置为 `paused_by_user`
29
29
 
30
+ 使用 `state-update` MCP 工具更新状态,确保通过 schema 校验和乐观锁。
31
+
30
32
  使用原子写入: 先写 `.gsd/state.json.tmp`,成功后 rename 为 `.gsd/state.json`
31
33
 
32
34
  ## STEP 3: 确认输出
@@ -324,12 +324,37 @@ function validateExtractedPackage(extractDir) {
324
324
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
325
325
  if (pkg.name !== 'gsd-lite') return false;
326
326
  if (!pkg.version || !/^\d+\.\d+\.\d+/.test(pkg.version)) return false;
327
+ // Verify install.js exists and is a regular file (lstat rejects symlinks)
328
+ const installPath = path.join(extractDir, 'install.js');
329
+ const lstat = fs.lstatSync(installPath);
330
+ if (!lstat.isFile()) return false;
327
331
  return true;
328
332
  } catch {
329
333
  return false;
330
334
  }
331
335
  }
332
336
 
337
+ // ── Tarball URL Validation ─────────────────────────────────
338
+ const ALLOWED_TARBALL_HOSTS = [
339
+ 'github.com',
340
+ 'api.github.com',
341
+ 'codeload.github.com',
342
+ 'objects.githubusercontent.com',
343
+ ];
344
+
345
+ function validateTarballUrl(url) {
346
+ if (!url) return false;
347
+ try {
348
+ const parsed = new URL(url);
349
+ if (parsed.protocol !== 'https:') return false;
350
+ return ALLOWED_TARBALL_HOSTS.some(
351
+ allowed => parsed.hostname === allowed || parsed.hostname.endsWith('.' + allowed),
352
+ );
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
357
+
333
358
  // ── Download & Install ─────────────────────────────────────
334
359
  async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
335
360
  const tmpDir = path.join(os.tmpdir(), `gsd-update-${Date.now()}`);
@@ -340,6 +365,9 @@ async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
340
365
 
341
366
  // Download tarball via fetch (no shell interpolation)
342
367
  if (verbose) console.log(' Downloading tarball...');
368
+ if (!validateTarballUrl(tarballUrl)) {
369
+ throw new Error(`Tarball URL failed host validation: ${(() => { try { return new URL(tarballUrl).hostname; } catch { return tarballUrl; } })()}`);
370
+ }
343
371
  const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'gsd-lite-auto-update/1.0' };
344
372
  if (token) headers.Authorization = `Bearer ${token}`;
345
373
 
@@ -347,7 +375,26 @@ async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
347
375
  const dlTimeout = setTimeout(() => controller.abort(), 30000);
348
376
  let tarData;
349
377
  try {
350
- const res = await fetch(tarballUrl, { signal: controller.signal, headers, redirect: 'follow' });
378
+ let res = await fetch(tarballUrl, { signal: controller.signal, headers, redirect: 'manual' });
379
+ // Handle redirect manually to prevent Authorization header leakage
380
+ if (res.status === 301 || res.status === 302) {
381
+ const location = res.headers.get('location');
382
+ if (!location || !validateTarballUrl(location)) {
383
+ throw new Error(`Redirect URL failed host validation: ${location || '(empty)'}`);
384
+ }
385
+ // Follow redirect WITHOUT Authorization header (prevent token leakage to CDN)
386
+ // Use redirect: 'manual' to validate any further redirects in the chain
387
+ const redirectHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'gsd-lite-auto-update/1.0' };
388
+ res = await fetch(location, { signal: controller.signal, headers: redirectHeaders, redirect: 'manual' });
389
+ // Handle one more potential redirect from CDN (e.g., 303/307/308)
390
+ if (res.status >= 300 && res.status < 400) {
391
+ const loc2 = res.headers.get('location');
392
+ if (!loc2 || !validateTarballUrl(loc2)) {
393
+ throw new Error(`Secondary redirect URL failed host validation: ${loc2 || '(empty)'}`);
394
+ }
395
+ res = await fetch(loc2, { signal: controller.signal, headers: redirectHeaders, redirect: 'error' });
396
+ }
397
+ }
351
398
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
352
399
  tarData = Buffer.from(await res.arrayBuffer());
353
400
  } finally {
@@ -452,7 +499,7 @@ function pruneOldCacheVersions(cacheBase, keepCount = 3, verbose = false) {
452
499
  try {
453
500
  if (!fs.existsSync(cacheBase)) return;
454
501
  const entries = fs.readdirSync(cacheBase, { withFileTypes: true })
455
- .filter(e => e.isDirectory())
502
+ .filter(e => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
456
503
  .map(e => e.name);
457
504
  if (entries.length <= keepCount) return;
458
505
 
@@ -581,6 +628,7 @@ module.exports = {
581
628
  shouldCheck,
582
629
  shouldSkipUpdateCheck,
583
630
  validateExtractedPackage,
631
+ validateTarballUrl,
584
632
  };
585
633
 
586
634
  // ── CLI Entry Point (for background auto-install) ─────────
@@ -53,11 +53,20 @@ setTimeout(() => process.exit(0), 4000).unref();
53
53
  const stableStatuslinePath = path.join(claudeDir, 'hooks', 'gsd-statusline.cjs');
54
54
  if (fs.existsSync(stableStatuslinePath)) {
55
55
  let settings = {};
56
+ let settingsParseError = false;
56
57
  try {
57
58
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
58
- } catch { /* Can't read settings — skip registration */ }
59
+ } catch (e) {
60
+ if (e.code === 'ENOENT') {
61
+ settings = {}; // File doesn't exist — create fresh
62
+ } else {
63
+ // Parse error or other — skip write to avoid overwriting corrupted file
64
+ if (process.env.GSD_DEBUG) console.error('[gsd-session-init] settings.json read error:', e.message);
65
+ settingsParseError = true;
66
+ }
67
+ }
59
68
 
60
- if (settings) {
69
+ if (!settingsParseError && settings) {
61
70
  const current = settings.statusLine?.command || '';
62
71
 
63
72
  if (current.includes('gsd-statusline')) {
@@ -120,12 +129,13 @@ setTimeout(() => process.exit(0), 4000).unref();
120
129
  const notifPath = path.join(claudeDir, 'gsd', 'runtime', 'update-notification.json');
121
130
  if (fs.existsSync(notifPath)) {
122
131
  const notif = JSON.parse(fs.readFileSync(notifPath, 'utf8'));
132
+ const safeSemver = (s) => /^\d+\.\d+\.\d+/.test(String(s || '')) ? String(s) : '?.?.?';
123
133
  if (notif.kind === 'updated') {
124
- console.log(`✅ GSD-Lite auto-updated: v${notif.from} → v${notif.to}`);
134
+ console.log(`✅ GSD-Lite auto-updated: v${safeSemver(notif.from)} → v${safeSemver(notif.to)}`);
125
135
  } else if (notif.kind === 'available' && notif.action === 'plugin_update') {
126
- console.log(`📦 GSD-Lite update available: v${notif.from} → v${notif.to}. Run /plugin update gsd`);
136
+ console.log(`📦 GSD-Lite update available: v${safeSemver(notif.from)} → v${safeSemver(notif.to)}. Run /plugin update gsd`);
127
137
  } else if (notif.kind === 'available') {
128
- console.log(`📦 GSD-Lite update available: v${notif.from} → v${notif.to}. Run gsd update`);
138
+ console.log(`📦 GSD-Lite update available: v${safeSemver(notif.from)} → v${safeSemver(notif.to)}. Run gsd update`);
129
139
  }
130
140
  fs.unlinkSync(notifPath);
131
141
  }
@@ -163,11 +173,14 @@ setTimeout(() => process.exit(0), 4000).unref();
163
173
  }
164
174
  } catch { /* skip */ }
165
175
 
176
+ // Sanitize user-controlled strings to prevent HTML/markdown injection
177
+ const safeName = (s) => String(s || '').replace(/<!--|-->/g, '').slice(0, 200);
178
+
166
179
  // Stdout: only output session-end warning (crash recovery), skip routine progress
167
180
  // Routine progress is handled by CLAUDE.md injection below — avoids noise
168
181
  const shortHead = progress.gitHead ? progress.gitHead.substring(0, 7) : 'n/a';
169
182
  if (sessionEndInfo) {
170
- console.log(`⚠️ GSD: Previous session ended unexpectedly at ${sessionEndInfo.ended_at} (was: ${sessionEndInfo.workflow_mode_was}). Run /gsd:resume to recover.`);
183
+ console.log(`⚠️ GSD: Previous session ended unexpectedly at ${sessionEndInfo.ended_at} (was: ${safeName(sessionEndInfo.workflow_mode_was)}). Run /gsd:resume to recover.`);
171
184
  }
172
185
 
173
186
  // Write status block to CLAUDE.md
@@ -178,13 +191,13 @@ setTimeout(() => process.exit(0), 4000).unref();
178
191
 
179
192
  const statusBlock = [
180
193
  BEGIN_MARKER,
181
- `### GSD Project: ${progress.project}`,
182
- `- Phase: ${progress.currentPhase || '?'}/${progress.totalPhases} (${progress.phaseName})`,
183
- `- Task: ${progress.currentTask || 'none'}${progress.taskName ? ` (${progress.taskName})` : ''}`,
184
- `- Mode: ${progress.workflowMode}`,
194
+ `### GSD Project: ${safeName(progress.project)}`,
195
+ `- Phase: ${progress.currentPhase || '?'}/${progress.totalPhases} (${safeName(progress.phaseName)})`,
196
+ `- Task: ${progress.currentTask || 'none'}${progress.taskName ? ` (${safeName(progress.taskName)})` : ''}`,
197
+ `- Mode: ${safeName(progress.workflowMode)}`,
185
198
  `- Progress: ${progress.acceptedTasks}/${progress.totalTasks} tasks done`,
186
- `- Last checkpoint: ${shortHead}`,
187
- sessionEndInfo ? `- ⚠️ Previous session ended unexpectedly (${sessionEndInfo.ended_at})` : null,
199
+ `- Last checkpoint: ${safeName(shortHead)}`,
200
+ sessionEndInfo ? `- ⚠️ Previous session ended unexpectedly (${safeName(sessionEndInfo.ended_at)})` : null,
188
201
  END_MARKER,
189
202
  ].filter(Boolean).join('\n');
190
203
 
@@ -9,10 +9,10 @@
9
9
  * @returns {number}
10
10
  */
11
11
  function semverSortComparator(a, b) {
12
- const pa = a.split('.').map(Number);
13
- const pb = b.split('.').map(Number);
12
+ const pa = a.split('.').map(s => parseInt(s, 10) || 0);
13
+ const pb = b.split('.').map(s => parseInt(s, 10) || 0);
14
14
  for (let i = 0; i < 3; i++) {
15
- if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
15
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
16
16
  }
17
17
  return 0;
18
18
  }
package/install.js CHANGED
@@ -264,7 +264,7 @@ export function main() {
264
264
  if (existsSync(cacheBase)) {
265
265
  try {
266
266
  const entries = readdirSync(cacheBase, { withFileTypes: true })
267
- .filter(e => e.isDirectory()).map(e => e.name);
267
+ .filter(e => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name)).map(e => e.name);
268
268
  if (entries.length > 3) {
269
269
  const sorted = entries.slice().sort(semverSortComparator);
270
270
  // Detect versions with active processes to avoid disrupting running sessions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,8 +33,10 @@ executor 上下文传递协议 (orchestrator → executor):
33
33
  ├── research_decisions: 从 research_basis 引用的 decision 摘要
34
34
  ├── predecessor_outputs: 前置依赖 task 的 files_changed + checkpoint_commit
35
35
  ├── project_conventions: CLAUDE.md 路径 (executor 自行读取)
36
- ├── workflows: 需加载的工作流文件路径 (如 tdd-cycle.md)
37
- └── constraints: retry_count / level / review_required
36
+ ├── workflows: 需加载的工作流文件路径 (如 tdd-cycle.md, deviation-rules.md; retry 时追加 debugging.md; 有 research_basis 时追加 research.md)
37
+ ├── constraints: retry_count / level / review_required
38
+ ├── debugger_guidance: debugger 分析结果 (root_cause / fix_direction / fix_attempts / evidence),仅在 debug_context 存在时提供,否则 null
39
+ └── rework_feedback: reviewer 返工反馈 (issue 描述数组),仅在 last_review_feedback 存在时提供,否则 null
38
40
  ```
39
41
 
40
42
  派发 `executor` 子代理执行单个 task。
@@ -146,6 +148,8 @@ remaining <= 25%:
146
148
  4. 立即停止
147
149
  ```
148
150
 
151
+ > **Note:** 上述 35%/25% 阈值为编排器主动发起上下文保存的建议阈值。Resume 时的恢复阻断阈值为 `CONTEXT_RESUME_THRESHOLD = 40`(服务端强制校验),低于 40% 时 resume 会拒绝恢复并要求 /clear。
152
+
149
153
  ---
150
154
 
151
155
  ## 依赖门槛语义 (Gate-aware dependencies)
@@ -7,7 +7,7 @@
7
7
  | 当前状态 | 允许的目标状态 |
8
8
  |----------|---------------|
9
9
  | `pending` | `running`, `blocked` |
10
- | `running` | `checkpointed`, `blocked`, `failed` |
10
+ | `running` | `checkpointed`, `blocked`, `failed`, `accepted` |
11
11
  | `checkpointed` | `accepted`, `needs_revalidation` |
12
12
  | `accepted` | `needs_revalidation` |
13
13
  | `blocked` | `pending` |
@@ -24,6 +24,7 @@ stateDiagram-v2
24
24
  pending --> blocked : executor 报告阻塞
25
25
 
26
26
  running --> checkpointed : executor 完成 checkpoint
27
+ running --> accepted : L0/review_required=false 自动接受 (跳过 checkpointed)
27
28
  running --> blocked : executor 运行时阻塞
28
29
  running --> failed : executor 执行失败
29
30
 
@@ -172,8 +173,11 @@ stateDiagram-v2
172
173
  executing_task --> failed : debugger 报告架构问题
173
174
 
174
175
  reviewing_task --> executing_task : 审查完成 (通过或返工)
175
- reviewing_phase --> executing_task : 审查返工 ( critical)
176
- reviewing_phase --> completed : 最终 phase 审查通过
176
+ reviewing_phase --> executing_task : 审查完成 (通过或返工,reviewer 始终返回 executing_task)
177
+ reviewing_phase --> completed : 最终 phase 审查通过 (schema 允许)
178
+
179
+ note right of executing_task : 最终 phase 审查通过后,\nresume 返回 complete_phase action,\nLLM 调用 phase-complete 设置 completed
180
+ executing_task --> completed : phase-complete (最终 phase)
177
181
 
178
182
  awaiting_clear --> executing_task : /clear + /resume 后恢复
179
183
  awaiting_user --> executing_task : 用户解除阻塞 / 自动匹配 decision
@@ -182,14 +186,18 @@ stateDiagram-v2
182
186
  executing_task --> preflight_overrides : resume 时 preflight 检测
183
187
  preflight_overrides --> reconcile_workspace : git HEAD 不匹配
184
188
  preflight_overrides --> replan_required : 计划文件被修改
185
- preflight_overrides --> research_refresh_needed : 研究缓存过期
186
189
  preflight_overrides --> awaiting_user : 方向漂移检测
190
+ preflight_overrides --> executing_task : dirty-phase 回滚 (rollback_to_dirty_phase)
191
+ preflight_overrides --> research_refresh_needed : 研究缓存过期
187
192
 
188
193
  research_refresh_needed --> executing_task : 研究刷新完成
189
194
  research_refresh_needed --> reviewing_task : 刷新后恢复审查状态
190
195
  research_refresh_needed --> reviewing_phase : 刷新后恢复审查状态
191
196
 
192
197
  paused_by_user --> executing_task : 用户恢复
198
+ paused_by_user --> research_refresh_needed : resume 时研究过期
199
+ paused_by_user --> reviewing_task : resume 恢复审查状态
200
+ paused_by_user --> reviewing_phase : resume 恢复审查状态
193
201
 
194
202
  completed --> [*]
195
203
  failed --> [*]
@@ -198,7 +206,8 @@ stateDiagram-v2
198
206
  ### 关键转换说明
199
207
 
200
208
  **执行主路径**:
201
- `planning -> executing_task -> reviewing_phase -> executing_task (next phase) -> ... -> completed`
209
+ `planning -> executing_task -> reviewing_phase -> executing_task -> complete_phase -> executing_task (next phase) -> ... -> executing_task -> phase-complete -> completed`
210
+ 注: `reviewing_phase` 审查通过后始终先回到 `executing_task`,再由 resume 返回 `complete_phase` action,LLM 调用 `phase-complete` MCP tool 推进。最终 phase 的 `phase-complete` 调用会直接设置 `workflow_mode = 'completed'`。
202
211
 
203
212
  **L2 审查分支**:
204
213
  `executing_task -> reviewing_task -> executing_task`
@@ -211,7 +220,8 @@ stateDiagram-v2
211
220
  1. git HEAD 不匹配 -> `reconcile_workspace`
212
221
  2. 计划文件被外部修改 -> `replan_required`
213
222
  3. 方向漂移 -> `awaiting_user`
214
- 4. 研究缓存过期 -> `research_refresh_needed`
223
+ 4. `current_phase` 之前的 phase 有 `needs_revalidation` task -> `rollback_to_dirty_phase`
224
+ 5. 研究缓存过期 -> `research_refresh_needed`
215
225
 
216
226
  **Research 刷新后恢复**:
217
227
  `storeResearch()` 中: 如果 `workflow_mode === 'research_refresh_needed'`,调用 `inferWorkflowModeAfterResearch()` 根据 `current_review` 状态推断恢复到 `reviewing_phase` / `reviewing_task` / `executing_task`。
package/src/schema.js CHANGED
@@ -602,7 +602,7 @@ export function validateReviewerResult(r) {
602
602
  if (!(typeof r.scope_id === 'string' || typeof r.scope_id === 'number') || r.scope_id === '' || r.scope_id === 0) {
603
603
  errors.push('missing or invalid scope_id');
604
604
  }
605
- if (!['L2', 'L1-batch', 'L1'].includes(r.review_level)) errors.push('invalid review_level (expected L2, L1-batch, or L1)');
605
+ if (!['L3', 'L2', 'L1-batch', 'L1'].includes(r.review_level)) errors.push('invalid review_level (expected L3, L2, L1-batch, or L1)');
606
606
  if (typeof r.spec_passed !== 'boolean') errors.push('spec_passed must be boolean');
607
607
  if (typeof r.quality_passed !== 'boolean') errors.push('quality_passed must be boolean');
608
608
  if (!Array.isArray(r.critical_issues)) errors.push('critical_issues must be array');
package/src/server.js CHANGED
@@ -375,7 +375,7 @@ export async function main() {
375
375
  process.on('SIGINT', () => process.exit(0));
376
376
  process.on('SIGTERM', () => process.exit(0));
377
377
  process.on('unhandledRejection', (err) => {
378
- if (process.env.GSD_DEBUG) console.error('[gsd] unhandledRejection', err);
378
+ process.stderr.write(`[gsd] unhandledRejection: ${err?.stack || err}\n`);
379
379
  });
380
380
 
381
381
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -32,7 +32,7 @@ const RESULT_CONTRACTS = {
32
32
  reviewer: {
33
33
  scope: '"task" | "phase"',
34
34
  scope_id: 'string | number — task id (e.g. "1.2") or phase number',
35
- review_level: '"L2" | "L1-batch" | "L1"',
35
+ review_level: '"L3" | "L2" | "L1-batch" | "L1"',
36
36
  spec_passed: 'boolean',
37
37
  quality_passed: 'boolean',
38
38
  critical_issues: '{ reason|description, task_id?, invalidates_downstream? }[] — blocking issues',
@@ -361,7 +361,7 @@ function buildErrorFingerprint(result) {
361
361
  parts.push([...result.files_changed].sort().join(','));
362
362
  }
363
363
  const combined = parts.filter(Boolean).join('|');
364
- return combined.length > 0 ? combined.slice(0, 120) : result.summary.slice(0, 80);
364
+ return combined.length > 0 ? combined.slice(0, 120) : (result.summary || '').slice(0, 80);
365
365
  }
366
366
 
367
367
  function getBlockedReasonFromResult(result) {
@@ -376,8 +376,8 @@ function getBlockedReasonFromResult(result) {
376
376
  };
377
377
  }
378
378
 
379
- async function persist(basePath, updates, { _append_decisions, _propagation_tasks } = {}) {
380
- const result = await update({ updates, basePath, _append_decisions, _propagation_tasks });
379
+ async function persist(basePath, updates, { _append_decisions, _propagation_tasks, expectedVersion } = {}) {
380
+ const result = await update({ updates, basePath, expectedVersion, _append_decisions, _propagation_tasks });
381
381
  if (result.error) {
382
382
  return result;
383
383
  }
@@ -385,8 +385,8 @@ async function persist(basePath, updates, { _append_decisions, _propagation_task
385
385
  }
386
386
 
387
387
  // persist variant that returns merged state from update(), avoiding re-reads
388
- async function persistAndRead(basePath, updates, { _append_decisions, _propagation_tasks } = {}) {
389
- const result = await update({ updates, basePath, _append_decisions, _propagation_tasks });
388
+ async function persistAndRead(basePath, updates, { _append_decisions, _propagation_tasks, expectedVersion } = {}) {
389
+ const result = await update({ updates, basePath, expectedVersion, _append_decisions, _propagation_tasks });
390
390
  if (result.error) {
391
391
  return { error: true, ...result };
392
392
  }
@@ -34,7 +34,7 @@ export function setLockPath(lockPath) {
34
34
  * Must be called before withStateLock in all mutation paths.
35
35
  */
36
36
  export function ensureLockPathFromStatePath(statePath) {
37
- if (!_fileLockPath && statePath) {
37
+ if (statePath) {
38
38
  _fileLockPath = join(dirname(statePath), 'state.lock');
39
39
  }
40
40
  }
@@ -44,6 +44,7 @@ export function withStateLock(fn) {
44
44
  if (_fileLockPath) {
45
45
  return withFileLock(_fileLockPath, fn);
46
46
  }
47
+ process.stderr.write('[gsd] WARNING: withStateLock called without lock path — cross-process safety not guaranteed\n');
47
48
  return fn();
48
49
  });
49
50
  _mutationQueue = p.catch(() => {});
@@ -606,8 +606,8 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
606
606
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
607
607
  return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'data must be a non-null object' };
608
608
  }
609
- if (typeof data.scope !== 'string') {
610
- return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'data.scope must be a string' };
609
+ if (typeof data.scope !== 'string' || data.scope.length === 0) {
610
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'data.scope must be a non-empty string' };
611
611
  }
612
612
 
613
613
  const statePath = await getStatePath(basePath);
@@ -906,7 +906,8 @@ function _applyPatchOp(state, op) {
906
906
  }
907
907
 
908
908
  case 'update_task': {
909
- const { task_id, task: taskObj, ...fields } = op;
909
+ // Destructure envelope keys explicitly so they don't leak into fields
910
+ const { task_id, task: taskObj, op: _op, phase_id: _pid, ...fields } = op;
910
911
  if (typeof task_id !== 'string') return { error: true, message: 'task_id must be a string' };
911
912
 
912
913
  const phase = state.phases.find(p => p.todo?.some(t => t.id === task_id));
@@ -1,6 +1,7 @@
1
1
  // Automation/business logic functions
2
2
 
3
3
  import { dirname, join } from 'node:path';
4
+ import { writeFileSync, unlinkSync } from 'node:fs';
4
5
  import { writeFile, rename, unlink } from 'node:fs/promises';
5
6
  import { ensureDir, readJson, writeJson, getStatePath } from '../../utils.js';
6
7
  import {
@@ -445,6 +446,12 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
445
446
  const researchDir = join(gsdDir, 'research');
446
447
  await ensureDir(researchDir);
447
448
 
449
+ // Crash-consistency sentinel: marks the window between artifact renames and
450
+ // state.json write. On recovery (future iteration), presence of this file
451
+ // indicates a potentially inconsistent research state.
452
+ const sentinelPath = join(gsdDir, '.research-commit-pending');
453
+ writeFileSync(sentinelPath, JSON.stringify({ timestamp: Date.now(), pid: process.pid }));
454
+
448
455
  // Atomic multi-file write: write all artifacts first, then rename in batch
449
456
  const normalizedArtifacts = normalizeResearchArtifacts(artifacts);
450
457
  const tmpSuffix = `.${process.pid}-${Date.now()}.tmp`;
@@ -465,6 +472,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
465
472
  for (const { tmp } of tmpPaths) {
466
473
  try { await unlink(tmp); } catch {}
467
474
  }
475
+ try { unlinkSync(sentinelPath); } catch {}
468
476
  throw err;
469
477
  }
470
478
 
@@ -501,11 +509,16 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
501
509
 
502
510
  const validation = validateState(state);
503
511
  if (!validation.valid) {
512
+ try { unlinkSync(sentinelPath); } catch {}
504
513
  return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `State validation failed: ${validation.errors.join('; ')}` };
505
514
  }
506
515
 
507
516
  state._version = (state._version ?? 0) + 1;
508
517
  await writeJson(statePath, state);
518
+
519
+ // Remove sentinel after successful state write — crash consistency window closed
520
+ try { unlinkSync(sentinelPath); } catch {}
521
+
509
522
  return {
510
523
  success: true,
511
524
  workflow_mode: state.workflow_mode,
@@ -29,12 +29,12 @@ function summarizeOutput(output, lines) {
29
29
 
30
30
  async function runCommand(command, args, cwd) {
31
31
  try {
32
- const { stdout } = await execFile(command, args, {
32
+ const { stdout, stderr } = await execFile(command, args, {
33
33
  cwd,
34
34
  encoding: 'utf-8',
35
35
  timeout: 120000,
36
36
  });
37
- return { exit_code: 0, summary: summarizeOutput(stdout, 3) };
37
+ return { exit_code: 0, summary: summarizeOutput(stdout || stderr, 3) };
38
38
  } catch (err) {
39
39
  return {
40
40
  exit_code: err.status ?? (typeof err.code === 'number' ? err.code : 1),
@@ -131,7 +131,7 @@ debugger 由编排器在以下情况派发:
131
131
 
132
132
  **目标:** 提出根因修复方案 (不是症状),交由 executor 实施。
133
133
 
134
- > 调试器不直接写代码 — 返回 fix_direction + 测试用例描述,由 executor 实施。
134
+ > 调试器不直接写代码 (无 Write 工具) — 返回 fix_direction + 测试用例描述,由 executor 实施。
135
135
 
136
136
  ### 步骤 1: 描述回归测试用例
137
137
 
@@ -131,7 +131,7 @@
131
131
  1. 调用 `orchestrator-resume` 获取 action
132
132
  2. 按 action 执行对应操作 (见下方 action 处理表)
133
133
  3. 操作完成后回到步骤 1
134
- 4. 终止: action ∈ {idle, awaiting_user, completed, failed, await_manual_intervention}
134
+ 4. 终止: action ∈ {idle, awaiting_user, noop, phase_failed, task_failed, await_manual_intervention, await_recovery_decision, review_retry_exhausted}
135
135
 
136
136
  不要在循环中间停下来等用户确认 — 让编排器驱动。
137
137
 
@@ -151,6 +151,14 @@
151
151
  | `replan_required` | 计划文件被修改。**自动处理:** 确认计划无误后,调用 `state-update({updates: {workflow_mode: "executing_task"}})` → 继续循环 |
152
152
  | `reconcile_workspace` | Git HEAD 不一致。检查变更,调用 `state-update({updates: {git_head: "<当前HEAD>", workflow_mode: "executing_task"}})` → 继续循环 |
153
153
  | `rollback_to_dirty_phase` | 早期 phase 有失效 task。**自动处理:** 继续循环 (resume 已回滚 current_phase) |
154
+ | `trigger_review` | 所有 task 已 checkpointed,触发 phase review → 继续循环 (resume 会自动 dispatch_reviewer) |
155
+ | `phase_failed` | debugger 报告架构问题,phase 标记 failed。向用户展示失败信息 |
156
+ | `task_failed` | debugger 报告 task 不可修复 (非架构问题),task 标记 failed。继续循环 (如有其他可运行 task) 或向用户报告 |
157
+ | `review_retry_exhausted` | phase 审查返工次数超限。向用户展示问题,等待用户干预 |
158
+ | `research_stored` | researcher 结果已存储。继续循环 |
159
+ | `awaiting_user` | task 被阻塞或方向漂移,需要用户输入。展示 blockers 列表,等待用户解除 |
160
+ | `await_manual_intervention` | 上下文不足 / 项目暂停 / 计划阶段。根据场景执行: awaiting_clear 时执行 /clear + /resume; paused 时确认恢复; planning 时完成计划并 state-init |
161
+ | `noop` | 工作流已完成 (completed 状态),无需操作。展示完成信息和 PR 建议 |
154
162
  | `idle` | 当前 phase 无可运行 task。检查 task 状态和依赖关系,必要时向用户报告 |
155
163
  | `await_recovery_decision` | 工作流处于 failed 状态。向用户展示失败信息和恢复选项 (retry/skip/replan) |
156
164
 
@@ -124,7 +124,7 @@ Decision ID 供 plan/task 的 `research_basis` 字段引用,建立研究→计
124
124
 
125
125
  ---
126
126
 
127
- ## 结果契约 (与 researcher 一致)
127
+ ## 结果契约 (与 researcher 一致,完整 3 参数调用契约见 `agents/researcher.md`)
128
128
 
129
129
  ```json
130
130
  {