helloagents 3.0.33 → 3.0.35

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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +3 -4
  3. package/README.md +70 -71
  4. package/README_CN.md +70 -71
  5. package/bootstrap-lite.md +9 -11
  6. package/bootstrap.md +21 -23
  7. package/gemini-extension.json +1 -1
  8. package/install.ps1 +21 -3
  9. package/install.sh +19 -2
  10. package/package.json +2 -2
  11. package/scripts/capability-registry.mjs +5 -3
  12. package/scripts/cli-doctor-codex.mjs +150 -1
  13. package/scripts/cli-doctor-render.mjs +2 -1
  14. package/scripts/cli-lifecycle-hosts.mjs +76 -34
  15. package/scripts/cli-lifecycle.mjs +50 -15
  16. package/scripts/cli-messages.mjs +5 -5
  17. package/scripts/delivery-gate-messages.mjs +5 -4
  18. package/scripts/delivery-gate.mjs +11 -22
  19. package/scripts/guard.mjs +1 -1
  20. package/scripts/notify-closeout.mjs +61 -22
  21. package/scripts/notify-context.mjs +5 -5
  22. package/scripts/notify-route.mjs +1 -1
  23. package/scripts/notify.mjs +2 -2
  24. package/scripts/plan-contract.mjs +10 -14
  25. package/scripts/project-session-cleanup.mjs +45 -31
  26. package/scripts/qa-review-state.mjs +313 -0
  27. package/scripts/ralph-loop.mjs +32 -13
  28. package/scripts/runtime-scope.mjs +1 -3
  29. package/scripts/session-capsule.mjs +51 -13
  30. package/scripts/state-document.mjs +77 -0
  31. package/scripts/workflow-core.mjs +13 -19
  32. package/scripts/workflow-plan-files.mjs +1 -1
  33. package/scripts/workflow-recommendation.mjs +55 -67
  34. package/scripts/workflow-state.mjs +8 -8
  35. package/skills/commands/auto/SKILL.md +12 -12
  36. package/skills/commands/build/SKILL.md +9 -10
  37. package/skills/commands/commit/SKILL.md +1 -1
  38. package/skills/commands/help/SKILL.md +11 -13
  39. package/skills/commands/init/SKILL.md +18 -9
  40. package/skills/commands/loop/SKILL.md +70 -96
  41. package/skills/commands/plan/SKILL.md +7 -8
  42. package/skills/commands/prd/SKILL.md +3 -3
  43. package/skills/commands/qa/SKILL.md +49 -0
  44. package/skills/hello-ui/SKILL.md +3 -3
  45. package/skills/helloagents/SKILL.md +11 -14
  46. package/skills/qa-review/SKILL.md +92 -0
  47. package/templates/plans/contract.json +4 -7
  48. package/templates/plans/plan.md +1 -1
  49. package/templates/plans/tasks.md +1 -1
  50. package/templates/verify.yaml +1 -1
  51. package/scripts/review-state.mjs +0 -193
  52. package/scripts/verify-state.mjs +0 -175
  53. package/skills/commands/global/SKILL.md +0 -71
  54. package/skills/commands/verify/SKILL.md +0 -46
  55. package/skills/commands/wiki/SKILL.md +0 -57
  56. package/skills/hello-review/SKILL.md +0 -42
  57. package/skills/hello-verify/SKILL.md +0 -144
@@ -24,10 +24,8 @@ function issueHeading(issue) {
24
24
  return '任务缺少可交付元数据'
25
25
  case 'missing-contract':
26
26
  return '方案包缺少可信的结构化契约'
27
- case 'missing-verify-evidence':
28
- return '当前工作流缺少最新验证证据'
29
- case 'missing-review-evidence':
30
- return '当前工作流缺少最新审查证据'
27
+ case 'missing-qa-review-evidence':
28
+ return '当前工作流缺少最新 qa-review 证据'
31
29
  case 'missing-advisor-evidence':
32
30
  return '当前工作流缺少最新 advisor 证据'
33
31
  case 'missing-visual-evidence':
@@ -62,6 +60,9 @@ export function buildDeliveryBlockReason(issues, recommendation, gateHint) {
62
60
  if (issues.some((issue) => issue.type === 'missing-visual-evidence')) {
63
61
  lines.push('视觉验收动作:先写入当前会话 `artifacts/visual.json`,记录 `tooling`、`screensChecked`、`statesChecked`、`status` 和 `summary`,再报告完成。')
64
62
  }
63
+ if (issues.some((issue) => issue.type === 'missing-qa-review-evidence')) {
64
+ lines.push('质量闭环动作:先完成 `~qa` 或写入当前会话 `artifacts/qa-review.json`,记录结论、问题定位、验证命令与最新结果,再报告完成。')
65
+ }
65
66
  if (gateHint) {
66
67
  lines.push(gateHint)
67
68
  }
@@ -9,10 +9,9 @@ import { fileURLToPath } from 'node:url'
9
9
  import { getAdvisorEvidenceStatus } from './advisor-state.mjs'
10
10
  import { getCloseoutEvidenceStatus } from './closeout-state.mjs'
11
11
  import { getAdvisorRequirement, getVisualValidationRequirement } from './plan-contract.mjs'
12
+ import { getQaReviewEvidenceStatus } from './qa-review-state.mjs'
12
13
  import { getVisualEvidenceStatus } from './visual-state.mjs'
13
14
  import { buildDeliveryGateHint, getDeliveryAction, getWorkflowRecommendation, getWorkflowSnapshot } from './workflow-state.mjs'
14
- import { getReviewEvidenceStatus } from './review-state.mjs'
15
- import { getVerifyEvidenceStatus } from './verify-state.mjs'
16
15
  import { buildDeliveryBlockReason, buildUnderSpecifiedDetails } from './delivery-gate-messages.mjs'
17
16
 
18
17
  function selectGatePlans(snapshot) {
@@ -84,20 +83,12 @@ function collectPlanIssues(planEntries) {
84
83
  return issues
85
84
  }
86
85
 
87
- function collectEvidenceIssues(issues, verificationStatus, reviewStatus, advisorStatus, visualStatus, closeoutStatus) {
88
- if (verificationStatus?.required && verificationStatus.status !== 'valid') {
86
+ function collectEvidenceIssues(issues, qaStatus, advisorStatus, visualStatus, closeoutStatus) {
87
+ if (qaStatus?.required && qaStatus.status !== 'valid') {
89
88
  issues.push({
90
- type: 'missing-verify-evidence',
89
+ type: 'missing-qa-review-evidence',
91
90
  planName: 'delivery',
92
- details: verificationStatus.details,
93
- })
94
- }
95
-
96
- if (reviewStatus?.required && reviewStatus.status !== 'valid') {
97
- issues.push({
98
- type: 'missing-review-evidence',
99
- planName: 'delivery',
100
- details: reviewStatus.details,
91
+ details: qaStatus.details,
101
92
  })
102
93
  }
103
94
  if (advisorStatus?.required && advisorStatus.status !== 'valid') {
@@ -124,9 +115,9 @@ function collectEvidenceIssues(issues, verificationStatus, reviewStatus, advisor
124
115
  }
125
116
  }
126
117
 
127
- function collectGateIssues(planEntries, verificationStatus, reviewStatus, advisorStatus, visualStatus, closeoutStatus) {
118
+ function collectGateIssues(planEntries, qaStatus, advisorStatus, visualStatus, closeoutStatus) {
128
119
  const issues = collectPlanIssues(planEntries)
129
- collectEvidenceIssues(issues, verificationStatus, reviewStatus, advisorStatus, visualStatus, closeoutStatus)
120
+ collectEvidenceIssues(issues, qaStatus, advisorStatus, visualStatus, closeoutStatus)
130
121
  return issues
131
122
  }
132
123
 
@@ -143,11 +134,10 @@ export function evaluateDeliveryGate(data = {}) {
143
134
  const workflowOptions = { payload: data }
144
135
  const snapshot = getWorkflowSnapshot(cwd, workflowOptions)
145
136
  const recommendation = getWorkflowRecommendation(cwd, workflowOptions)
146
- const verificationStatus = getVerifyEvidenceStatus(cwd, workflowOptions)
147
137
  const deliveryAction = getDeliveryAction(cwd, workflowOptions)
148
138
  const gatePlans = selectGatePlans(snapshot)
149
- const reviewStatus = getReviewEvidenceStatus(cwd, {
150
- required: deliveryAction?.phase === 'verify' && deliveryAction?.mode === 'review-first',
139
+ const qaStatus = getQaReviewEvidenceStatus(cwd, {
140
+ required: deliveryAction?.phase === 'qa' || deliveryAction?.phase === 'consolidate',
151
141
  ...workflowOptions,
152
142
  })
153
143
  if (gatePlans.length === 0) {
@@ -169,8 +159,7 @@ export function evaluateDeliveryGate(data = {}) {
169
159
  })
170
160
  const closeoutRequired = (
171
161
  gatePlans.every((entry) => entry.missingFiles.length === 0 && entry.templateIssues.length === 0 && entry.taskSummary.total > 0 && entry.taskSummary.open === 0 && entry.taskSummary.underSpecifiedCount === 0)
172
- && (!verificationStatus.required || verificationStatus.status === 'valid')
173
- && (!reviewStatus.required || reviewStatus.status === 'valid')
162
+ && (!qaStatus.required || qaStatus.status === 'valid')
174
163
  && (!advisorStatus.required || advisorStatus.status === 'valid')
175
164
  && (!visualStatus.required || visualStatus.status === 'valid')
176
165
  )
@@ -179,7 +168,7 @@ export function evaluateDeliveryGate(data = {}) {
179
168
  ...workflowOptions,
180
169
  })
181
170
 
182
- const issues = collectGateIssues(gatePlans, verificationStatus, reviewStatus, advisorStatus, visualStatus, closeoutStatus)
171
+ const issues = collectGateIssues(gatePlans, qaStatus, advisorStatus, visualStatus, closeoutStatus)
183
172
  if (issues.length === 0) {
184
173
  return { suppressOutput: true }
185
174
  }
package/scripts/guard.mjs CHANGED
@@ -76,7 +76,7 @@ function buildHighRiskGate(matches, cwd, payload = {}) {
76
76
  if (!recommendation) return null
77
77
  if (matches.some((match) => match.gate === 'post-verify')) {
78
78
  return {
79
- reason: `[HelloAGENTS Guard] 已阻止 T3 命令:当前工作流尚未进入 VERIFY / CONSOLIDATE。\n当前工作流:${recommendation.summary}\n处理路径:${recommendation.nextPath}\n${recommendation.guidance}`,
79
+ reason: `[HelloAGENTS Guard] 已阻止 T3 命令:当前工作流尚未进入 QA / CONSOLIDATE。\n当前工作流:${recommendation.summary}\n处理路径:${recommendation.nextPath}\n${recommendation.guidance}`,
80
80
  }
81
81
  }
82
82
  if (matches.some((match) => match.gate === 'plan-first') && recommendation.nextCommand === 'plan') {
@@ -1,15 +1,56 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
- import { dirname } from 'node:path'
3
+ import { dirname, join } from 'node:path'
4
+ import { homedir } from 'node:os'
4
5
 
5
- import { getRuntimeEvidencePath, readRuntimeEvidence, writeRuntimeEvidence } from './runtime-artifacts.mjs'
6
-
7
- export const CODEX_CLOSEOUT_EVIDENCE_FILE = 'codex-native-stop.json'
8
- export const CODEX_QUICK_NOTIFY_EVIDENCE_FILE = 'codex-quick-notify.json'
9
- const CODEX_CLOSEOUT_LOCK_FILE = 'codex-native-stop.lock'
6
+ export const CODEX_NOTIFY_STATE_FILE = 'notify-state.json'
7
+ export const CODEX_NOTIFY_LOCK_FILE = 'notify.lock'
10
8
  const WEAK_KEY_TTL_MS = 10_000
11
9
  const LOCK_STALE_MS = 120_000
12
10
 
11
+ function getHomeDir(env = process.env) {
12
+ return env.HOME || env.USERPROFILE || homedir()
13
+ }
14
+
15
+ export function getCodexNotifyDir(env = process.env) {
16
+ return join(getHomeDir(env), '.codex', '.helloagents')
17
+ }
18
+
19
+ export function getCodexNotifyStatePath(env = process.env) {
20
+ return join(getCodexNotifyDir(env), CODEX_NOTIFY_STATE_FILE)
21
+ }
22
+
23
+ export function getCodexNotifyLockPath(env = process.env) {
24
+ return join(getCodexNotifyDir(env), CODEX_NOTIFY_LOCK_FILE)
25
+ }
26
+
27
+ function readNotifyState(env = process.env) {
28
+ try {
29
+ const value = JSON.parse(readFileSync(getCodexNotifyStatePath(env), 'utf-8'))
30
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
31
+ return { version: 1, nativeStop: null, quickNotify: null }
32
+ }
33
+ return {
34
+ version: 1,
35
+ nativeStop: value.nativeStop && typeof value.nativeStop === 'object' ? value.nativeStop : null,
36
+ quickNotify: value.quickNotify && typeof value.quickNotify === 'object' ? value.quickNotify : null,
37
+ }
38
+ } catch {
39
+ return { version: 1, nativeStop: null, quickNotify: null }
40
+ }
41
+ }
42
+
43
+ function writeNotifyState(state, env = process.env) {
44
+ const filePath = getCodexNotifyStatePath(env)
45
+ mkdirSync(dirname(filePath), { recursive: true })
46
+ writeFileSync(filePath, `${JSON.stringify({
47
+ version: 1,
48
+ nativeStop: state?.nativeStop || null,
49
+ quickNotify: state?.quickNotify || null,
50
+ }, null, 2)}\n`, 'utf-8')
51
+ return filePath
52
+ }
53
+
13
54
  function getTurnId(payload = {}) {
14
55
  return String(payload.turnId || payload.turn_id || payload['turn-id'] || '').trim()
15
56
  }
@@ -118,13 +159,10 @@ export function matchesCodexCloseoutEvidence(evidence, snapshot, now = Date.now(
118
159
  return intersects(snapshot.weakKeys, weakKeys)
119
160
  }
120
161
 
121
- /**
122
- * Try to claim the current Codex closeout so Stop and native notify handle one turn only once.
123
- */
124
162
  export function beginCodexCloseoutClaim(cwd, { payload = {}, turnState = null, source = '' } = {}) {
125
163
  const snapshot = buildCodexCloseoutSnapshot({ payload, turnState })
126
- const lockPath = getRuntimeEvidencePath(cwd, CODEX_CLOSEOUT_LOCK_FILE, { payload })
127
- const evidencePath = getRuntimeEvidencePath(cwd, CODEX_CLOSEOUT_EVIDENCE_FILE, { payload })
164
+ const lockPath = getCodexNotifyLockPath()
165
+ const evidencePath = getCodexNotifyStatePath()
128
166
  const now = Date.now()
129
167
  const lockPayload = {
130
168
  source,
@@ -164,8 +202,8 @@ export function beginCodexCloseoutClaim(cwd, { payload = {}, turnState = null, s
164
202
  }
165
203
  }
166
204
 
167
- const evidence = readRuntimeEvidence(cwd, CODEX_CLOSEOUT_EVIDENCE_FILE, { payload })
168
- if (matchesCodexCloseoutEvidence(evidence, snapshot, now)) {
205
+ const notifyState = readNotifyState()
206
+ if (matchesCodexCloseoutEvidence(notifyState.nativeStop, snapshot, now)) {
169
207
  releaseLockFile(lockPath)
170
208
  return {
171
209
  claimed: false,
@@ -186,15 +224,13 @@ export function beginCodexCloseoutClaim(cwd, { payload = {}, turnState = null, s
186
224
  }
187
225
  }
188
226
 
189
- /**
190
- * Persist the handled closeout fingerprint and release the in-flight lock.
191
- */
192
227
  export function finalizeCodexCloseoutClaim(claim, meta = {}) {
193
228
  if (!claim?.claimed) return
194
229
 
195
230
  try {
196
231
  if (meta.handled !== false) {
197
- writeRuntimeEvidence(claim.cwd, CODEX_CLOSEOUT_EVIDENCE_FILE, {
232
+ const notifyState = readNotifyState()
233
+ notifyState.nativeStop = {
198
234
  version: 2,
199
235
  updatedAt: new Date().toISOString(),
200
236
  source: meta.source || claim.source || '',
@@ -205,7 +241,8 @@ export function finalizeCodexCloseoutClaim(claim, meta = {}) {
205
241
  messageHash: claim.snapshot.messageHash,
206
242
  strongKeys: claim.snapshot.strongKeys,
207
243
  weakKeys: claim.snapshot.weakKeys,
208
- }, { payload: claim.payload })
244
+ }
245
+ writeNotifyState(notifyState)
209
246
  }
210
247
  } finally {
211
248
  releaseLockFile(claim.lockPath)
@@ -214,7 +251,8 @@ export function finalizeCodexCloseoutClaim(claim, meta = {}) {
214
251
 
215
252
  export function writeCodexQuickNotifyEvidence(cwd, { payload = {}, turnState = null, event = '' } = {}) {
216
253
  const snapshot = buildCodexCloseoutSnapshot({ payload, turnState })
217
- return writeRuntimeEvidence(cwd, CODEX_QUICK_NOTIFY_EVIDENCE_FILE, {
254
+ const notifyState = readNotifyState()
255
+ notifyState.quickNotify = {
218
256
  version: 1,
219
257
  updatedAt: new Date().toISOString(),
220
258
  event,
@@ -223,11 +261,12 @@ export function writeCodexQuickNotifyEvidence(cwd, { payload = {}, turnState = n
223
261
  messageHash: snapshot.messageHash,
224
262
  strongKeys: snapshot.strongKeys,
225
263
  weakKeys: snapshot.weakKeys,
226
- }, { payload })
264
+ }
265
+ return writeNotifyState(notifyState)
227
266
  }
228
267
 
229
268
  export function hasCodexQuickNotifyEvidence(cwd, { payload = {}, turnState = null } = {}) {
230
269
  const snapshot = buildCodexCloseoutSnapshot({ payload, turnState })
231
- const evidence = readRuntimeEvidence(cwd, CODEX_QUICK_NOTIFY_EVIDENCE_FILE, { payload })
232
- return matchesCodexCloseoutEvidence(evidence, snapshot)
270
+ const notifyState = readNotifyState()
271
+ return matchesCodexCloseoutEvidence(notifyState.quickNotify, snapshot)
233
272
  }
@@ -11,7 +11,7 @@ import {
11
11
  const COMMAND_ALIASES = {
12
12
  do: 'build',
13
13
  design: 'plan',
14
- review: 'verify',
14
+ review: 'qa',
15
15
  };
16
16
 
17
17
  function buildRuntimeRootBlock(pkgRoot) {
@@ -49,7 +49,7 @@ function buildAliasRouteNote(skillName) {
49
49
  return '兼容别名映射:本次按 ~plan 规则执行;方案文件使用 `plan.md`,项目级 UI 契约仍使用 `DESIGN.md`。';
50
50
  }
51
51
  if (skillName === 'review') {
52
- return '兼容别名映射:本次按 ~verify 的审查优先模式执行。';
52
+ return '兼容别名映射:本次按 ~qa 规则执行;统一走 qa-review 质量闭环。';
53
53
  }
54
54
  return '';
55
55
  }
@@ -137,7 +137,7 @@ export function buildInjectContext({ source, bootstrap, settings, pkgRoot, host,
137
137
  if (capabilityHint) context += `\n\n## 当前按需能力\n${capabilityHint}`;
138
138
  if (stateSyncHint) context += `\n\n## 状态文件提醒\n${stateSyncHint}`;
139
139
  context += settingsBlock;
140
- if (source === 'resume' || source === 'compact') {
140
+ if ((source === 'resume' || source === 'compact') && stateSnapshot.exists) {
141
141
  context += `\n\n> ⚠️ 会话已恢复/压缩,请先读取 \`state_path\` 指向的 \`${stateSnapshot.statePath.replace(/\\/g, '/')}\`;先看当前用户消息,如果仍是同一任务,再参考状态文件。`;
142
142
  }
143
143
  return context;
@@ -165,8 +165,8 @@ export function buildSemanticRouteInstruction(cwd, payload = {}) {
165
165
  '请根据用户请求的真实意图选路,不依赖关键词表。',
166
166
  buildDelegatedTaskHint(),
167
167
  'Delivery Tier: T0=探索/比较;T1=低风险小改动或显式验证;T2=多文件功能/新项目/需要结构化产物;T3=高风险或不可逆操作。',
168
- '路由映射:~idea=只读探索,不创建文件;~build=明确实现;~verify=审查/验证;~plan=结构化规划;~prd=重型规格;~auto=自动选择并继续执行后续阶段。',
169
- '若判定为 T3,默认先走 ~plan / ~prd;纯审查/验证请求才优先 ~verify。',
168
+ '路由映射:~idea=只读探索,不创建文件;~build=明确实现;~qa=统一质量审查/验证/修复/收尾;~plan=结构化规划;~prd=重型规格;~auto=自动选择并继续执行后续阶段。',
169
+ '若判定为 T3,默认先走 ~plan / ~prd;纯质量审查、验真或收尾请求才优先 ~qa。',
170
170
  `涉及 UI 任务时,设计决策优先级:当前活跃 plan / PRD → ${describeProjectStoreFile(cwd, 'DESIGN.md')} → 已读取的 hello-ui 规则;同时所有 UI 任务都必须满足 UI 质量基线。`,
171
171
  projectStorageHint,
172
172
  workflowHint ? `项目状态:${workflowHint}` : '',
@@ -22,7 +22,7 @@ function shouldBypassRoute(prompt) {
22
22
 
23
23
  function buildHelpExtraRules(skillName) {
24
24
  if (skillName !== 'help') return ''
25
- return ' 这是 HelloAGENTS 的帮助命令,不是宿主 CLI 的内置帮助。仅显示 HelloAGENTS 的帮助和当前设置;优先使用当前会话上下文中已注入的“当前用户设置”、配置文件原始 JSON 或此前读取结果摘要,上下文不存在或缺少要展示的配置项时才读取一次 ~/.helloagents/helloagents.json;自动激活技能说明仅在全局模式或已初始化项目时生效。不要调用宿主 CLI 的帮助工具(如 cli_help 或 /help),不要使用子代理,不要读取项目文件;若受工作区限制无法读取配置,必须明确说明并按已知默认值或已注入设置展示。'
25
+ return ' 这是 HelloAGENTS 的帮助命令,不是宿主 CLI 的内置帮助。仅显示 HelloAGENTS 的帮助和当前设置;优先使用当前会话上下文中已注入的“当前用户设置”、配置文件原始 JSON 或此前读取结果摘要,上下文不存在或缺少要展示的配置项时才读取一次 ~/.helloagents/helloagents.json;自动激活技能说明仅在宿主全局模式或已初始化项目时生效。不要调用宿主 CLI 的帮助工具(如 cli_help 或 /help),不要使用子代理,不要读取项目文件;若受工作区限制无法读取配置,必须明确说明并按已知默认值或已注入设置展示。'
26
26
  }
27
27
 
28
28
  function routeExplicitCommand({
@@ -41,7 +41,7 @@ const EVENT_NAME = {
41
41
  UserPromptSubmit: IS_GEMINI ? 'BeforeAgent' : 'UserPromptSubmit',
42
42
  PreCompact: IS_GEMINI ? 'BeforeAgent' : 'PreCompact',
43
43
  };
44
- const RALPH_LOOP_ROUTE_COMMANDS = new Set(['verify', 'loop']);
44
+ const RALPH_LOOP_ROUTE_COMMANDS = new Set(['qa', 'loop']);
45
45
  const CODEX_HOOKS_FILE = join(homedir(), '.codex', 'hooks.json');
46
46
  const GATE_MODULE_LOADERS = {
47
47
  'turn-stop-gate': () => import('./turn-stop-gate.mjs'),
@@ -459,7 +459,7 @@ function cmdInject() {
459
459
  const cwd = payload.cwd || process.cwd();
460
460
  const settings = getSettings();
461
461
  const bootstrapFile = resolveBootstrapFile(cwd, settings, HOST);
462
- const shouldEnsureProjectLocal = bootstrapFile === 'bootstrap.md' || source === 'resume' || source === 'compact';
462
+ const shouldEnsureProjectLocal = isProjectRuntimeActive(cwd);
463
463
 
464
464
  startReplaySession(cwd, {
465
465
  host: HOST,
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
  import { resolveProjectPlanDir } from './project-storage.mjs'
5
5
 
6
6
  export const PLAN_CONTRACT_FILE_NAME = 'contract.json'
7
- const VALID_VERIFY_MODES = new Set(['test-first', 'review-first'])
7
+ const VALID_QA_MODES = new Set(['standard', 'deep'])
8
8
  const VALID_ADVISOR_SOURCES = new Set(['claude', 'codex', 'gemini'])
9
9
 
10
10
  function normalizeStringArray(values) {
@@ -14,9 +14,9 @@ function normalizeStringArray(values) {
14
14
  .filter(Boolean))]
15
15
  }
16
16
 
17
- function normalizeVerifyMode(value) {
17
+ function normalizeQaMode(value) {
18
18
  const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
19
- return VALID_VERIFY_MODES.has(normalized) ? normalized : ''
19
+ return VALID_QA_MODES.has(normalized) ? normalized : ''
20
20
  }
21
21
 
22
22
  function normalizeUiStyleAdvisorContract(input = {}) {
@@ -88,12 +88,11 @@ export function readPlanContract(planDir) {
88
88
 
89
89
  export function normalizePlanContract(input = {}) {
90
90
  return {
91
- version: 1,
91
+ version: 2,
92
92
  source: typeof input.source === 'string' && input.source.trim() ? input.source.trim() : 'manual',
93
93
  originCommand: typeof input.originCommand === 'string' ? input.originCommand.trim() : '',
94
- verifyMode: normalizeVerifyMode(input.verifyMode),
95
- reviewerFocus: normalizeStringArray(input.reviewerFocus),
96
- testerFocus: normalizeStringArray(input.testerFocus),
94
+ qaMode: normalizeQaMode(input.qaMode),
95
+ qaFocus: normalizeStringArray(input.qaFocus),
97
96
  ui: normalizeUiContract(input.ui),
98
97
  advisor: normalizeAdvisorContract(input.advisor),
99
98
  }
@@ -128,14 +127,11 @@ export function getPlanContractIssues(contract = null) {
128
127
  const advisorRequirement = getAdvisorRequirement(normalized)
129
128
  const visualValidation = getVisualValidationRequirement(normalized)
130
129
  const issues = []
131
- if (!normalizeVerifyMode(normalized.verifyMode)) {
132
- issues.push('contract.json missing valid verifyMode')
130
+ if (!normalizeQaMode(normalized.qaMode)) {
131
+ issues.push('contract.json missing valid qaMode')
133
132
  }
134
- if (normalizeStringArray(normalized.testerFocus).length === 0) {
135
- issues.push('contract.json missing testerFocus')
136
- }
137
- if (normalizeVerifyMode(normalized.verifyMode) === 'review-first' && normalizeStringArray(normalized.reviewerFocus).length === 0) {
138
- issues.push('contract.json missing reviewerFocus for review-first flow')
133
+ if (normalizeStringArray(normalized.qaFocus).length === 0) {
134
+ issues.push('contract.json missing qaFocus')
139
135
  }
140
136
  if (normalized.ui?.required && normalizeStringArray(normalized.ui.sourcePriority).length === 0) {
141
137
  issues.push('contract.json missing ui.sourcePriority')
@@ -1,19 +1,18 @@
1
- import { existsSync, readdirSync, rmSync } from 'node:fs'
1
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import {
5
5
  ACTIVE_SESSION_FILE_NAME,
6
- CAPSULE_FILE_NAME,
7
- EVENTS_FILE_NAME,
8
- PROJECT_ARTIFACTS_DIR_NAME,
9
6
  PROJECT_SESSIONS_DIR_NAME,
10
7
  getProjectActivationDir,
11
8
  getProjectRoot,
12
9
  readJsonFile,
13
10
  writeJsonFileAtomic,
14
11
  } from './runtime-scope.mjs'
12
+ import { LONG_RUNNING_TTL_MS } from './runtime-ttl.mjs'
15
13
 
16
14
  export const PROJECT_SESSION_CLEANUP_COOLDOWN_MS = 10 * 60 * 1000
15
+ export const PROJECT_SESSION_MAX_AGE_MS = LONG_RUNNING_TTL_MS
17
16
 
18
17
  function removePath(filePath, result, bucket) {
19
18
  try {
@@ -33,29 +32,6 @@ function isDirectoryEmptyRecursive(dirPath) {
33
32
  })
34
33
  }
35
34
 
36
- function listFilesRecursive(dirPath) {
37
- const entries = readdirSync(dirPath, { withFileTypes: true })
38
- return entries.flatMap((entry) => {
39
- const entryPath = join(dirPath, entry.name)
40
- if (entry.isDirectory()) {
41
- return listFilesRecursive(entryPath).map((child) => `${entry.name}/${child}`)
42
- }
43
- return entry.isFile() ? [entry.name] : []
44
- })
45
- }
46
-
47
- function isRouteOnlySessionDir(sessionDir) {
48
- if (existsSync(join(sessionDir, 'STATE.md'))) return false
49
- const files = listFilesRecursive(sessionDir).map((file) => file.replace(/\\/g, '/'))
50
- if (files.length === 0) return false
51
- if (!files.includes(`${PROJECT_ARTIFACTS_DIR_NAME}/codex-native-stop.json`)) return false
52
- return files.every((file) => [
53
- CAPSULE_FILE_NAME,
54
- EVENTS_FILE_NAME,
55
- `${PROJECT_ARTIFACTS_DIR_NAME}/codex-native-stop.json`,
56
- ].includes(file))
57
- }
58
-
59
35
  function shouldKeepSession(active, workspace, session) {
60
36
  const activeWorkspace = active.workspace || active.branch || ''
61
37
  return activeWorkspace === workspace && active.session === session
@@ -75,7 +51,35 @@ function writeCleanupCheckpoint(activePath, active, now) {
75
51
  })
76
52
  }
77
53
 
78
- export function cleanupProjectSessions(cwd, { now = Date.now(), minIntervalMs = 0 } = {}) {
54
+ function hasStateSnapshot(sessionDir) {
55
+ return existsSync(join(sessionDir, 'STATE.md'))
56
+ }
57
+
58
+ function readSessionStateMtimeMs(sessionDir) {
59
+ try {
60
+ return statSync(join(sessionDir, 'STATE.md')).mtimeMs
61
+ } catch {
62
+ return 0
63
+ }
64
+ }
65
+
66
+ function isStaleStateSession(sessionDir, now, maxAgeMs) {
67
+ const mtimeMs = readSessionStateMtimeMs(sessionDir)
68
+ return !Number.isFinite(mtimeMs) || mtimeMs <= 0 || (now - mtimeMs > maxAgeMs)
69
+ }
70
+
71
+ function isTransientSessionTemp(entryName = '') {
72
+ return /^\.[0-9]+-[0-9a-f-]+\.tmp$/i.test(entryName)
73
+ }
74
+
75
+ function cleanupTransientSessionTemps(sessionsDir, result) {
76
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
77
+ if (!entry.isFile() || !isTransientSessionTemp(entry.name)) continue
78
+ removePath(join(sessionsDir, entry.name), result, 'removedTempFiles')
79
+ }
80
+ }
81
+
82
+ export function cleanupProjectSessions(cwd, { now = Date.now(), minIntervalMs = 0, maxAgeMs = PROJECT_SESSION_MAX_AGE_MS } = {}) {
79
83
  const projectRoot = getProjectRoot(cwd)
80
84
  const activationDir = getProjectActivationDir(projectRoot)
81
85
  const sessionsDir = join(activationDir, PROJECT_SESSIONS_DIR_NAME)
@@ -84,7 +88,9 @@ export function cleanupProjectSessions(cwd, { now = Date.now(), minIntervalMs =
84
88
  const result = {
85
89
  sessionsDir,
86
90
  removedEmptyDirs: [],
87
- removedRouteOnlyDirs: [],
91
+ removedInactiveDirs: [],
92
+ removedNoStateDirs: [],
93
+ removedTempFiles: [],
88
94
  errors: [],
89
95
  skipped: false,
90
96
  }
@@ -98,6 +104,12 @@ export function cleanupProjectSessions(cwd, { now = Date.now(), minIntervalMs =
98
104
  }
99
105
  }
100
106
 
107
+ try {
108
+ cleanupTransientSessionTemps(sessionsDir, result)
109
+ } catch (error) {
110
+ result.errors.push(`${sessionsDir}: ${error.message}`)
111
+ }
112
+
101
113
  for (const workspaceEntry of readdirSync(sessionsDir, { withFileTypes: true })) {
102
114
  if (!workspaceEntry.isDirectory()) continue
103
115
  const workspaceDir = join(sessionsDir, workspaceEntry.name)
@@ -110,8 +122,10 @@ export function cleanupProjectSessions(cwd, { now = Date.now(), minIntervalMs =
110
122
  try {
111
123
  if (isDirectoryEmptyRecursive(sessionDir)) {
112
124
  removePath(sessionDir, result, 'removedEmptyDirs')
113
- } else if (isRouteOnlySessionDir(sessionDir)) {
114
- removePath(sessionDir, result, 'removedRouteOnlyDirs')
125
+ } else if (!hasStateSnapshot(sessionDir)) {
126
+ removePath(sessionDir, result, 'removedNoStateDirs')
127
+ } else if (isStaleStateSession(sessionDir, now, maxAgeMs)) {
128
+ removePath(sessionDir, result, 'removedInactiveDirs')
115
129
  }
116
130
  } catch (error) {
117
131
  result.errors.push(`${sessionDir}: ${error.message}`)