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.
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -4
- package/README.md +72 -73
- package/README_CN.md +72 -73
- package/bootstrap-lite.md +10 -12
- package/bootstrap.md +22 -24
- package/gemini-extension.json +1 -1
- package/install.ps1 +21 -3
- package/install.sh +19 -2
- package/package.json +2 -2
- package/scripts/capability-registry.mjs +5 -3
- package/scripts/cli-doctor-codex.mjs +150 -1
- package/scripts/cli-doctor-render.mjs +2 -1
- package/scripts/cli-lifecycle-hosts.mjs +76 -34
- package/scripts/cli-lifecycle.mjs +50 -15
- package/scripts/cli-messages.mjs +5 -5
- package/scripts/delivery-gate-messages.mjs +5 -4
- package/scripts/delivery-gate.mjs +11 -22
- package/scripts/guard.mjs +1 -1
- package/scripts/notify-closeout.mjs +61 -22
- package/scripts/notify-context.mjs +6 -6
- package/scripts/notify-payload.mjs +8 -0
- package/scripts/notify-route.mjs +1 -1
- package/scripts/notify-ui.mjs +14 -1
- package/scripts/notify.mjs +80 -4
- package/scripts/plan-contract.mjs +10 -14
- package/scripts/project-session-cleanup.mjs +45 -31
- package/scripts/qa-review-state.mjs +313 -0
- package/scripts/ralph-loop.mjs +86 -14
- package/scripts/runtime-scope.mjs +1 -3
- package/scripts/session-capsule.mjs +51 -13
- package/scripts/state-document.mjs +77 -0
- package/scripts/workflow-core.mjs +13 -19
- package/scripts/workflow-plan-files.mjs +1 -1
- package/scripts/workflow-recommendation.mjs +55 -67
- package/scripts/workflow-state.mjs +8 -8
- package/skills/commands/auto/SKILL.md +12 -12
- package/skills/commands/build/SKILL.md +9 -10
- package/skills/commands/commit/SKILL.md +1 -1
- package/skills/commands/help/SKILL.md +11 -13
- package/skills/commands/init/SKILL.md +18 -9
- package/skills/commands/loop/SKILL.md +70 -96
- package/skills/commands/plan/SKILL.md +7 -8
- package/skills/commands/prd/SKILL.md +3 -3
- package/skills/commands/qa/SKILL.md +49 -0
- package/skills/hello-ui/SKILL.md +3 -3
- package/skills/helloagents/SKILL.md +12 -15
- package/skills/qa-review/SKILL.md +92 -0
- package/templates/plans/contract.json +4 -7
- package/templates/plans/plan.md +1 -1
- package/templates/plans/tasks.md +1 -1
- package/templates/verify.yaml +1 -1
- package/scripts/review-state.mjs +0 -193
- package/scripts/verify-state.mjs +0 -175
- package/skills/commands/global/SKILL.md +0 -71
- package/skills/commands/verify/SKILL.md +0 -46
- package/skills/commands/wiki/SKILL.md +0 -57
- package/skills/hello-review/SKILL.md +0 -42
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
114
|
-
removePath(sessionDir, result, '
|
|
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
|
+
}
|
package/scripts/ralph-loop.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* HelloAGENTS
|
|
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 {
|
|
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 (
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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: '
|
|
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: '⚠️ [
|
|
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
|
-
|
|
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: `[
|
|
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: `[
|
|
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
|
-
|
|
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}`,
|