helloagents 3.0.32 → 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 (59) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +3 -4
  3. package/README.md +72 -73
  4. package/README_CN.md +72 -73
  5. package/bootstrap-lite.md +10 -12
  6. package/bootstrap.md +22 -24
  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 +6 -6
  22. package/scripts/notify-payload.mjs +8 -0
  23. package/scripts/notify-route.mjs +1 -1
  24. package/scripts/notify-ui.mjs +14 -1
  25. package/scripts/notify.mjs +80 -4
  26. package/scripts/plan-contract.mjs +10 -14
  27. package/scripts/project-session-cleanup.mjs +45 -31
  28. package/scripts/qa-review-state.mjs +313 -0
  29. package/scripts/ralph-loop.mjs +86 -14
  30. package/scripts/runtime-scope.mjs +1 -3
  31. package/scripts/session-capsule.mjs +51 -13
  32. package/scripts/state-document.mjs +77 -0
  33. package/scripts/workflow-core.mjs +13 -19
  34. package/scripts/workflow-plan-files.mjs +1 -1
  35. package/scripts/workflow-recommendation.mjs +55 -67
  36. package/scripts/workflow-state.mjs +8 -8
  37. package/skills/commands/auto/SKILL.md +12 -12
  38. package/skills/commands/build/SKILL.md +9 -10
  39. package/skills/commands/commit/SKILL.md +1 -1
  40. package/skills/commands/help/SKILL.md +11 -13
  41. package/skills/commands/init/SKILL.md +18 -9
  42. package/skills/commands/loop/SKILL.md +70 -96
  43. package/skills/commands/plan/SKILL.md +7 -8
  44. package/skills/commands/prd/SKILL.md +3 -3
  45. package/skills/commands/qa/SKILL.md +49 -0
  46. package/skills/hello-ui/SKILL.md +3 -3
  47. package/skills/helloagents/SKILL.md +12 -15
  48. package/skills/qa-review/SKILL.md +92 -0
  49. package/templates/plans/contract.json +4 -7
  50. package/templates/plans/plan.md +1 -1
  51. package/templates/plans/tasks.md +1 -1
  52. package/templates/verify.yaml +1 -1
  53. package/scripts/review-state.mjs +0 -193
  54. package/scripts/verify-state.mjs +0 -175
  55. package/skills/commands/global/SKILL.md +0 -71
  56. package/skills/commands/verify/SKILL.md +0 -46
  57. package/skills/commands/wiki/SKILL.md +0 -57
  58. package/skills/hello-review/SKILL.md +0 -42
  59. package/skills/hello-verify/SKILL.md +0 -144
@@ -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}`)
@@ -0,0 +1,313 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { appendReplayEvent } from './replay-state.mjs'
6
+ import {
7
+ captureWorkspaceFingerprint,
8
+ clearRuntimeEvidence,
9
+ getRuntimeEvidencePath,
10
+ getRuntimeEvidenceRelativePath,
11
+ readRuntimeEvidence,
12
+ validateEvidenceFingerprint,
13
+ validateEvidenceTimestamp,
14
+ writeRuntimeEvidence,
15
+ } from './runtime-artifacts.mjs'
16
+ import { getProjectVerifyYamlPath } from './project-storage.mjs'
17
+
18
+ export const QA_REVIEW_EVIDENCE_FILE_NAME = 'qa-review.json'
19
+ const VALID_QA_MODES = new Set(['standard', 'deep'])
20
+ const VALID_QA_OUTCOMES = new Set(['clean', 'findings'])
21
+ const SHELL_OPERATORS = /[;&|`$(){}\n\r]/
22
+
23
+ function normalizeStringArray(values) {
24
+ if (!Array.isArray(values)) return []
25
+ return [...new Set(values
26
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
27
+ .filter(Boolean))]
28
+ }
29
+
30
+ function normalizeQaMode(value) {
31
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
32
+ return VALID_QA_MODES.has(normalized) ? normalized : ''
33
+ }
34
+
35
+ function normalizeQaOutcome(value) {
36
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
37
+ return VALID_QA_OUTCOMES.has(normalized) ? normalized : ''
38
+ }
39
+
40
+ function normalizeCommands(values) {
41
+ return normalizeStringArray(values)
42
+ }
43
+
44
+ function loadVerifyYaml(cwd) {
45
+ const filePath = getProjectVerifyYamlPath(cwd)
46
+ if (!existsSync(filePath)) return null
47
+ try {
48
+ const content = readFileSync(filePath, 'utf-8')
49
+ const commands = []
50
+ let inCommands = false
51
+ for (const line of content.split('\n')) {
52
+ const trimmed = line.trim()
53
+ if (trimmed.startsWith('commands:')) {
54
+ inCommands = true
55
+ continue
56
+ }
57
+ if (!inCommands) continue
58
+ if (trimmed.startsWith('- ') && !trimmed.startsWith('# ')) {
59
+ const command = trimmed.slice(2).trim().replace(/^["']|["']$/g, '')
60
+ if (command && !command.startsWith('#')) commands.push(command)
61
+ continue
62
+ }
63
+ if (trimmed && !trimmed.startsWith('#')) break
64
+ }
65
+ return commands.length > 0 ? commands : null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ function detectFromPackageJson(cwd) {
72
+ const filePath = join(cwd, 'package.json')
73
+ if (!existsSync(filePath)) return []
74
+ try {
75
+ const scripts = JSON.parse(readFileSync(filePath, 'utf-8')).scripts || {}
76
+ return ['lint', 'typecheck', 'type-check', 'test', 'build']
77
+ .filter((key) => key in scripts)
78
+ .map((key) => `npm run ${key}`)
79
+ } catch {
80
+ return []
81
+ }
82
+ }
83
+
84
+ function detectFromPyproject(cwd) {
85
+ const filePath = join(cwd, 'pyproject.toml')
86
+ if (!existsSync(filePath)) return []
87
+ try {
88
+ const content = readFileSync(filePath, 'utf-8')
89
+ const commands = []
90
+ if (content.includes('[tool.ruff')) commands.push('ruff check .')
91
+ if (content.includes('[tool.mypy')) commands.push('mypy .')
92
+ if (content.includes('[tool.pytest')) commands.push('pytest --tb=short -q')
93
+ return commands
94
+ } catch {
95
+ return []
96
+ }
97
+ }
98
+
99
+ export function detectCommands(cwd) {
100
+ const yamlCommands = loadVerifyYaml(cwd)
101
+ if (yamlCommands?.length) return yamlCommands
102
+ const packageJsonCommands = detectFromPackageJson(cwd)
103
+ if (packageJsonCommands.length > 0) return packageJsonCommands
104
+ return detectFromPyproject(cwd)
105
+ }
106
+
107
+ export function hasUnsafeQaCommand(commands = []) {
108
+ return commands.some((command) => SHELL_OPERATORS.test(command))
109
+ }
110
+
111
+ export function getQaReviewEvidencePath(cwd, options = {}) {
112
+ return getRuntimeEvidencePath(cwd, QA_REVIEW_EVIDENCE_FILE_NAME, options)
113
+ }
114
+
115
+ export function readQaReviewEvidence(cwd, options = {}) {
116
+ return readRuntimeEvidence(cwd, QA_REVIEW_EVIDENCE_FILE_NAME, options)
117
+ }
118
+
119
+ export function clearQaReviewEvidence(cwd, options = {}) {
120
+ clearRuntimeEvidence(cwd, QA_REVIEW_EVIDENCE_FILE_NAME, options)
121
+ }
122
+
123
+ export function normalizeQaReviewEvidence(input = {}) {
124
+ return {
125
+ source: typeof input.source === 'string' && input.source.trim() ? input.source.trim() : 'manual',
126
+ originCommand: typeof input.originCommand === 'string' ? input.originCommand.trim() : '',
127
+ qaMode: normalizeQaMode(input.qaMode) || 'standard',
128
+ scope: typeof input.scope === 'string' ? input.scope.trim() : '',
129
+ outcome: normalizeQaOutcome(input.outcome),
130
+ conclusion: typeof input.conclusion === 'string' ? input.conclusion.trim() : '',
131
+ findings: normalizeStringArray(input.findings),
132
+ fileReferences: normalizeStringArray(input.fileReferences),
133
+ commands: normalizeCommands(input.commands),
134
+ fastOnly: Boolean(input.fastOnly),
135
+ }
136
+ }
137
+
138
+ export function writeQaReviewEvidence(cwd, {
139
+ source = 'manual',
140
+ originCommand = '',
141
+ qaMode = 'standard',
142
+ scope = '',
143
+ outcome = '',
144
+ conclusion = '',
145
+ findings = [],
146
+ fileReferences = [],
147
+ commands = [],
148
+ fastOnly = false,
149
+ } = {}, options = {}) {
150
+ const normalized = normalizeQaReviewEvidence({
151
+ source,
152
+ originCommand,
153
+ qaMode,
154
+ scope,
155
+ outcome,
156
+ conclusion,
157
+ findings,
158
+ fileReferences,
159
+ commands,
160
+ fastOnly,
161
+ })
162
+ const payload = {
163
+ schemaVersion: 2,
164
+ updatedAt: new Date().toISOString(),
165
+ source: normalized.source,
166
+ originCommand: normalized.originCommand,
167
+ qaMode: normalized.qaMode,
168
+ scope: normalized.scope,
169
+ outcome: normalized.outcome,
170
+ conclusion: normalized.conclusion,
171
+ findings: normalized.findings,
172
+ fileReferences: normalized.fileReferences,
173
+ commands: normalized.commands,
174
+ fastOnly: normalized.fastOnly,
175
+ fingerprint: captureWorkspaceFingerprint(cwd),
176
+ }
177
+ writeRuntimeEvidence(cwd, QA_REVIEW_EVIDENCE_FILE_NAME, payload, options)
178
+ appendReplayEvent(cwd, {
179
+ event: 'qa_review_evidence_written',
180
+ source: normalized.source,
181
+ skillName: normalized.originCommand,
182
+ payload: options.payload || {},
183
+ details: {
184
+ qaMode: normalized.qaMode,
185
+ scope: normalized.scope,
186
+ outcome: normalized.outcome,
187
+ conclusion: normalized.conclusion,
188
+ findings: normalized.findings,
189
+ fileReferences: normalized.fileReferences,
190
+ commands: normalized.commands,
191
+ fastOnly: normalized.fastOnly,
192
+ },
193
+ artifacts: [getRuntimeEvidenceRelativePath(cwd, QA_REVIEW_EVIDENCE_FILE_NAME, options)],
194
+ })
195
+ return payload
196
+ }
197
+
198
+ function readRequiredQaReviewEvidence(cwd, options = {}) {
199
+ const evidence = readQaReviewEvidence(cwd, options)
200
+ if (evidence) return { evidence }
201
+ return {
202
+ error: {
203
+ required: true,
204
+ status: 'missing',
205
+ details: ['缺少当前工作流的成功 qa-review 证据'],
206
+ },
207
+ }
208
+ }
209
+
210
+ function validateQaReviewTimestamp(evidence, now) {
211
+ return validateEvidenceTimestamp(evidence, now, 'qa-review 证据')
212
+ }
213
+
214
+ function validateQaReviewFingerprint(cwd, evidence) {
215
+ return validateEvidenceFingerprint(cwd, evidence, '成功 qa-review 证据')
216
+ }
217
+
218
+ function validateQaReviewEvidence(commands, evidence) {
219
+ if (!normalizeQaOutcome(evidence.outcome) || !String(evidence.conclusion || '').trim()) {
220
+ return {
221
+ required: true,
222
+ status: 'invalid',
223
+ evidence,
224
+ commands,
225
+ details: ['qa-review 证据必须记录明确的 outcome 和 conclusion'],
226
+ }
227
+ }
228
+ if (commands.length > 0 && normalizeCommands(evidence.commands).length === 0) {
229
+ return {
230
+ required: true,
231
+ status: 'invalid',
232
+ evidence,
233
+ commands,
234
+ details: ['qa-review 证据必须记录本次实际执行的验证命令'],
235
+ }
236
+ }
237
+ if (Boolean(evidence.fastOnly)) {
238
+ return {
239
+ required: true,
240
+ status: 'fast-only',
241
+ evidence,
242
+ commands,
243
+ details: ['最新 qa-review 证据只覆盖快速命令检查,未完成完整 qa-review 闭环'],
244
+ }
245
+ }
246
+ if (normalizeQaOutcome(evidence.outcome) !== 'clean') {
247
+ return {
248
+ required: true,
249
+ status: 'blocked',
250
+ evidence,
251
+ commands,
252
+ details: ['最新 qa-review 证据仍记录阻断问题'],
253
+ }
254
+ }
255
+ return null
256
+ }
257
+
258
+ export function getQaReviewEvidenceStatus(cwd, { required = false, now = Date.now(), ...options } = {}) {
259
+ const commands = detectCommands(cwd)
260
+ if (!required) {
261
+ return {
262
+ required: false,
263
+ status: 'not-applicable',
264
+ commands,
265
+ }
266
+ }
267
+
268
+ const requiredEvidence = readRequiredQaReviewEvidence(cwd, options)
269
+ if (requiredEvidence.error) return { ...requiredEvidence.error, commands }
270
+
271
+ const { evidence } = requiredEvidence
272
+ const timestampError = validateQaReviewTimestamp(evidence, now)
273
+ if (timestampError) return { ...timestampError, commands }
274
+
275
+ const fingerprintError = validateQaReviewFingerprint(cwd, evidence)
276
+ if (fingerprintError) return { ...fingerprintError, commands }
277
+
278
+ const evidenceError = validateQaReviewEvidence(commands, evidence)
279
+ if (evidenceError) return evidenceError
280
+
281
+ return {
282
+ required: true,
283
+ status: 'valid',
284
+ evidence,
285
+ commands,
286
+ }
287
+ }
288
+
289
+ function readStdinJson() {
290
+ try {
291
+ return JSON.parse(readFileSync(0, 'utf-8'))
292
+ } catch {
293
+ return {}
294
+ }
295
+ }
296
+
297
+ function main() {
298
+ const command = process.argv[2] || ''
299
+ if (command !== 'write') return
300
+
301
+ const input = readStdinJson()
302
+ const cwd = input.cwd || process.cwd()
303
+ const payload = writeQaReviewEvidence(cwd, input, { payload: input })
304
+ process.stdout.write(JSON.stringify({
305
+ suppressOutput: true,
306
+ path: getQaReviewEvidencePath(cwd, { payload: input }),
307
+ payload,
308
+ }))
309
+ }
310
+
311
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
312
+ main()
313
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * HelloAGENTS Ralph Loop — Quality verification gate
3
+ * HelloAGENTS QA Gate — Quality verification gate
4
4
  * Runs on SubagentStop (Claude Code) and Stop (Codex CLI).
5
5
  * Auto-detects lint/test commands and blocks if they fail.
6
6
  */
@@ -9,7 +9,13 @@ import { join } from 'node:path';
9
9
  import { execSync } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { clearVerifyEvidence, detectCommands, hasUnsafeVerifyCommand, writeVerifyEvidence } from './verify-state.mjs';
12
+ import {
13
+ clearQaReviewEvidence,
14
+ detectCommands,
15
+ getQaReviewEvidenceStatus,
16
+ hasUnsafeQaCommand,
17
+ writeQaReviewEvidence,
18
+ } from './qa-review-state.mjs';
13
19
  import {
14
20
  getRuntimeEvidencePath,
15
21
  readRuntimeEvidence,
@@ -70,7 +76,7 @@ function hasGitChanges(cwd) {
70
76
  function runVerify(commands, cwd) {
71
77
  const failures = [];
72
78
  for (const cmd of commands) {
73
- if (hasUnsafeVerifyCommand([cmd])) {
79
+ if (hasUnsafeQaCommand([cmd])) {
74
80
  failures.push({ cmd, output: '已阻止:验证命令不允许使用 shell 组合操作符' });
75
81
  continue;
76
82
  }
@@ -98,6 +104,59 @@ function getLastAssistantMessage(data = {}) {
98
104
  ).trim();
99
105
  }
100
106
 
107
+ function hasTruthyAgentFlag(value) {
108
+ if (typeof value === 'boolean') return value;
109
+ const normalized = String(value || '').trim().toLowerCase();
110
+ return ['1', 'true', 'yes', 'subagent'].includes(normalized);
111
+ }
112
+
113
+ function hasNonEmptyValue(value) {
114
+ return String(value || '').trim().length > 0;
115
+ }
116
+
117
+ function looksLikeCodexDelegatedTurn(data = {}) {
118
+ if (!data || typeof data !== 'object') return false;
119
+ const client = String(data.client || '').trim();
120
+ const inputMessages = Array.isArray(data.inputMessages) ? data.inputMessages : [];
121
+ return !client && inputMessages.length > 1;
122
+ }
123
+
124
+ function inferSubagentFromPayload(data = {}) {
125
+ if (!data || typeof data !== 'object') return false;
126
+
127
+ if ([data.isSubagent, data.subagent].some(hasTruthyAgentFlag)) {
128
+ return true;
129
+ }
130
+
131
+ const roleLike = [
132
+ data.role,
133
+ data.agentRole,
134
+ data.agent_role,
135
+ data.agentKind,
136
+ data.agent_kind,
137
+ data.kind,
138
+ ]
139
+ .map((value) => String(value || '').trim().toLowerCase())
140
+ .filter(Boolean);
141
+
142
+ if (roleLike.some((value) => ['subagent', 'delegate', 'delegated', 'worker', 'explorer'].includes(value))) {
143
+ return true;
144
+ }
145
+
146
+ if ([
147
+ data.parentAgentId,
148
+ data.parent_agent_id,
149
+ data.parentTurnId,
150
+ data.parent_turn_id,
151
+ data.delegatedByAgentId,
152
+ data.delegated_by_agent_id,
153
+ ].some(hasNonEmptyValue)) {
154
+ return true;
155
+ }
156
+
157
+ return looksLikeCodexDelegatedTurn(data);
158
+ }
159
+
101
160
  function hasHelloagentsWrapper(message = '') {
102
161
  if (!message.includes('【HelloAGENTS】')) return false;
103
162
  const firstNonEmptyLine = message
@@ -138,7 +197,7 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
138
197
 
139
198
  const cwd = data.cwd || process.cwd();
140
199
  const runtimeOptions = { payload: data };
141
- const isSubagent = runtime.isSubagent ?? IS_SUBAGENT;
200
+ const isSubagent = runtime.isSubagent === true || inferSubagentFromPayload(data) || IS_SUBAGENT;
142
201
  const hookEventName = runtime.hookEventName || HOOK_EVENT;
143
202
 
144
203
  if (isSubagent) {
@@ -166,17 +225,30 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
166
225
  const failures = runVerify(commands, cwd);
167
226
  if (failures.length === 0) {
168
227
  resetBreaker(cwd, runtimeOptions);
169
- writeVerifyEvidence(cwd, {
170
- commands: detectCommands(cwd),
171
- fastOnly: isSubagent,
172
- source: isSubagent ? 'subagent' : 'stop',
173
- }, runtimeOptions);
228
+ const qaStatus = getQaReviewEvidenceStatus(cwd, {
229
+ required: true,
230
+ ...runtimeOptions,
231
+ });
232
+ if (qaStatus.status !== 'valid') {
233
+ writeQaReviewEvidence(cwd, {
234
+ source: isSubagent ? 'subagent' : 'ralph-loop',
235
+ originCommand: 'qa',
236
+ qaMode: 'standard',
237
+ scope: isSubagent ? 'subagent-fast-check' : 'runtime-fast-check',
238
+ outcome: 'clean',
239
+ conclusion: '自动命令检查通过。',
240
+ findings: [],
241
+ fileReferences: [],
242
+ commands: detectCommands(cwd),
243
+ fastOnly: true,
244
+ }, runtimeOptions);
245
+ }
174
246
 
175
247
  if (isSubagent) {
176
248
  return {
177
249
  hookSpecificOutput: {
178
250
  hookEventName,
179
- additionalContext: '子代理快速验证通过(lint/typecheck)。请控制器审查变更后继续。',
251
+ additionalContext: '子代理快速 QA 命令检查通过(lint/typecheck)。请控制器继续完整 qa-review。',
180
252
  },
181
253
  suppressOutput: true,
182
254
  };
@@ -186,7 +258,7 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
186
258
  return {
187
259
  hookSpecificOutput: {
188
260
  hookEventName,
189
- additionalContext: '⚠️ [Ralph Loop] 验证通过但未检测到代码变更(git diff 为空)。如果确实完成了编码任务,请确认变更已保存。',
261
+ additionalContext: '⚠️ [QA Gate] 验证通过但未检测到代码变更(git diff 为空)。如果确实完成了编码任务,请确认变更已保存。',
190
262
  },
191
263
  suppressOutput: true,
192
264
  };
@@ -195,7 +267,7 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
195
267
  return { suppressOutput: true };
196
268
  }
197
269
 
198
- clearVerifyEvidence(cwd, runtimeOptions);
270
+ clearQaReviewEvidence(cwd, runtimeOptions);
199
271
  const breaker = readBreaker(cwd, runtimeOptions);
200
272
  breaker.consecutive_failures += 1;
201
273
  breaker.last_failure = new Date().toISOString();
@@ -207,7 +279,7 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
207
279
  const details = failures.map(f => `\u2717 ${f.cmd}\n${f.output}`).join('\n\n');
208
280
  return {
209
281
  decision: 'block',
210
- reason: `[Ralph Loop] 验证失败:\n\n${details}\n\n请先修复以上问题,再报告完成。${breakerWarning}`,
282
+ reason: `[QA Gate] 验证失败:\n\n${details}\n\n请先修复以上问题,再报告完成。${breakerWarning}`,
211
283
  suppressOutput: true,
212
284
  };
213
285
  }
@@ -225,7 +297,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
225
297
  } catch (error) {
226
298
  process.stdout.write(JSON.stringify({
227
299
  decision: 'block',
228
- reason: `[Ralph Loop] 验证脚本执行异常,已暂停完成通知。\n原因:${error?.message || error}`,
300
+ reason: `[QA Gate] 验证脚本执行异常,已暂停完成通知。\n原因:${error?.message || error}`,
229
301
  suppressOutput: true,
230
302
  }));
231
303
  }
@@ -12,7 +12,6 @@ import { FULL_CARRIER_PROFILE_MARKER } from './cli-utils.mjs'
12
12
  export const PROJECT_DIR_NAME = '.helloagents'
13
13
  export const PROJECT_SESSIONS_DIR_NAME = 'sessions'
14
14
  export const PROJECT_ARTIFACTS_DIR_NAME = 'artifacts'
15
- export const CAPSULE_FILE_NAME = 'capsule.json'
16
15
  export const EVENTS_FILE_NAME = 'events.jsonl'
17
16
  export const ACTIVE_SESSION_FILE_NAME = 'active.json'
18
17
  export const DEFAULT_STATE_SESSION_TOKEN = 'default'
@@ -463,7 +462,6 @@ export function getProjectSessionScope(cwd, options = {}) {
463
462
  activationDir,
464
463
  sessionDir,
465
464
  statePath: join(sessionDir, 'STATE.md'),
466
- capsulePath: join(sessionDir, CAPSULE_FILE_NAME),
467
465
  eventsPath: join(sessionDir, EVENTS_FILE_NAME),
468
466
  artifactsDir: join(sessionDir, PROJECT_ARTIFACTS_DIR_NAME),
469
467
  key: `${projectRoot}::${workspace}::${session}`,
@@ -493,7 +491,7 @@ function buildTransientRuntimeDir(cwd, options = {}) {
493
491
  session: token,
494
492
  sessionMode: token === DEFAULT_STATE_SESSION_TOKEN ? 'default' : 'transient-session',
495
493
  sessionDir: join(getUserRuntimeRoot(), hash),
496
- capsulePath: join(getUserRuntimeRoot(), hash, CAPSULE_FILE_NAME),
494
+ statePath: join(getUserRuntimeRoot(), hash, 'STATE.md'),
497
495
  eventsPath: join(getUserRuntimeRoot(), hash, EVENTS_FILE_NAME),
498
496
  artifactsDir: join(getUserRuntimeRoot(), hash, PROJECT_ARTIFACTS_DIR_NAME),
499
497
  key: `${normalizedCwd}::transient::${token}`,