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
@@ -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
  }
@@ -219,17 +225,30 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
219
225
  const failures = runVerify(commands, cwd);
220
226
  if (failures.length === 0) {
221
227
  resetBreaker(cwd, runtimeOptions);
222
- writeVerifyEvidence(cwd, {
223
- commands: detectCommands(cwd),
224
- fastOnly: isSubagent,
225
- source: isSubagent ? 'subagent' : 'stop',
226
- }, 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
+ }
227
246
 
228
247
  if (isSubagent) {
229
248
  return {
230
249
  hookSpecificOutput: {
231
250
  hookEventName,
232
- additionalContext: '子代理快速验证通过(lint/typecheck)。请控制器审查变更后继续。',
251
+ additionalContext: '子代理快速 QA 命令检查通过(lint/typecheck)。请控制器继续完整 qa-review。',
233
252
  },
234
253
  suppressOutput: true,
235
254
  };
@@ -239,7 +258,7 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
239
258
  return {
240
259
  hookSpecificOutput: {
241
260
  hookEventName,
242
- additionalContext: '⚠️ [Ralph Loop] 验证通过但未检测到代码变更(git diff 为空)。如果确实完成了编码任务,请确认变更已保存。',
261
+ additionalContext: '⚠️ [QA Gate] 验证通过但未检测到代码变更(git diff 为空)。如果确实完成了编码任务,请确认变更已保存。',
243
262
  },
244
263
  suppressOutput: true,
245
264
  };
@@ -248,7 +267,7 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
248
267
  return { suppressOutput: true };
249
268
  }
250
269
 
251
- clearVerifyEvidence(cwd, runtimeOptions);
270
+ clearQaReviewEvidence(cwd, runtimeOptions);
252
271
  const breaker = readBreaker(cwd, runtimeOptions);
253
272
  breaker.consecutive_failures += 1;
254
273
  breaker.last_failure = new Date().toISOString();
@@ -260,7 +279,7 @@ export function evaluateRalphLoop(data = {}, runtime = {}) {
260
279
  const details = failures.map(f => `\u2717 ${f.cmd}\n${f.output}`).join('\n\n');
261
280
  return {
262
281
  decision: 'block',
263
- reason: `[Ralph Loop] 验证失败:\n\n${details}\n\n请先修复以上问题,再报告完成。${breakerWarning}`,
282
+ reason: `[QA Gate] 验证失败:\n\n${details}\n\n请先修复以上问题,再报告完成。${breakerWarning}`,
264
283
  suppressOutput: true,
265
284
  };
266
285
  }
@@ -278,7 +297,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
278
297
  } catch (error) {
279
298
  process.stdout.write(JSON.stringify({
280
299
  decision: 'block',
281
- reason: `[Ralph Loop] 验证脚本执行异常,已暂停完成通知。\n原因:${error?.message || error}`,
300
+ reason: `[QA Gate] 验证脚本执行异常,已暂停完成通知。\n原因:${error?.message || error}`,
282
301
  suppressOutput: true,
283
302
  }));
284
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}`,
@@ -10,6 +10,7 @@ import {
10
10
  writeActiveProjectSession,
11
11
  writeJsonFileAtomic,
12
12
  } from './runtime-scope.mjs'
13
+ import { readStateDocument, writeStateDocument } from './state-document.mjs'
13
14
 
14
15
  export { getRuntimeScope }
15
16
 
@@ -68,7 +69,7 @@ function getScope(cwd, options = {}) {
68
69
  }
69
70
 
70
71
  export function getSessionCapsulePath(cwd = process.cwd(), options = {}) {
71
- return getScope(cwd, options).capsulePath
72
+ return getScope(cwd, options).statePath
72
73
  }
73
74
 
74
75
  export function getSessionEventsPath(cwd = process.cwd(), options = {}) {
@@ -93,8 +94,9 @@ export function getSessionArtifactRelativePath(cwd, fileName, options = {}) {
93
94
 
94
95
  export function readSessionCapsule(cwd = process.cwd(), options = {}) {
95
96
  const scope = getScope(cwd, options)
96
- const capsule = readJsonFile(scope.capsulePath, null)
97
- if (!capsule || typeof capsule !== 'object') return buildEmptyCapsule(scope)
97
+ const { metadata } = readStateDocument(scope.statePath)
98
+ const capsule = metadata && typeof metadata === 'object' ? metadata : null
99
+ if (!capsule || Array.isArray(capsule)) return buildEmptyCapsule(scope)
98
100
  return {
99
101
  ...buildEmptyCapsule(scope),
100
102
  ...capsule,
@@ -109,7 +111,24 @@ export function readSessionCapsule(cwd = process.cwd(), options = {}) {
109
111
  }
110
112
 
111
113
  export function writeSessionCapsule(cwd, capsule, options = {}) {
112
- const scope = getScope(cwd, options)
114
+ const normalizedOptions = normalizeOptions(options)
115
+ const scope = getScope(cwd, normalizedOptions)
116
+ const currentDocument = readStateDocument(scope.statePath)
117
+ const hasBody = Boolean(currentDocument.body && currentDocument.body.trim())
118
+ if (!hasBody && normalizedOptions.ensureProjectLocal !== true && !existsSync(scope.statePath)) {
119
+ return {
120
+ ...buildEmptyCapsule(scope),
121
+ ...capsule,
122
+ scope: scope.scope,
123
+ key: scope.key,
124
+ cwd: scope.cwd,
125
+ branch: scope.branch,
126
+ workspace: scope.workspace || scope.branch,
127
+ session: scope.session,
128
+ sessionMode: scope.sessionMode,
129
+ updatedAt: new Date().toISOString(),
130
+ }
131
+ }
113
132
  const nextCapsule = {
114
133
  ...buildEmptyCapsule(scope),
115
134
  ...capsule,
@@ -122,9 +141,12 @@ export function writeSessionCapsule(cwd, capsule, options = {}) {
122
141
  sessionMode: scope.sessionMode,
123
142
  updatedAt: new Date().toISOString(),
124
143
  }
125
- writeJsonFileAtomic(scope.capsulePath, nextCapsule)
144
+ writeStateDocument(scope.statePath, {
145
+ metadata: nextCapsule,
146
+ body: currentDocument.body,
147
+ })
126
148
  writeActiveProjectSession(scope, {
127
- env: normalizeOptions(options).env,
149
+ env: normalizedOptions.env,
128
150
  })
129
151
  return nextCapsule
130
152
  }
@@ -151,8 +173,8 @@ export function writeCapsuleSection(cwd, section, value, options = {}) {
151
173
  }
152
174
 
153
175
  export function clearCapsuleSection(cwd, section, options = {}) {
154
- const capsulePath = getSessionCapsulePath(cwd, options)
155
- if (!existsSync(capsulePath)) return false
176
+ const statePath = getSessionCapsulePath(cwd, options)
177
+ if (!existsSync(statePath)) return false
156
178
 
157
179
  const capsule = readSessionCapsule(cwd, options)
158
180
  if (!Object.prototype.hasOwnProperty.call(capsule, section)) return false
@@ -180,6 +202,13 @@ export function appendSessionEvent(cwd, eventPayload, options = {}) {
180
202
  const eventName = eventPayload?.event || ''
181
203
  if (!eventName) return ''
182
204
 
205
+ writeActiveProjectSession(scope, {
206
+ host: eventPayload.host || '',
207
+ source: eventPayload.source || eventName,
208
+ env: scopedOptions.env,
209
+ })
210
+ if (!shouldRecordSessionEvents(scopedOptions)) return ''
211
+
183
212
  mkdirSync(dirname(scope.eventsPath), { recursive: true })
184
213
  const payload = {
185
214
  ts: new Date().toISOString(),
@@ -192,17 +221,13 @@ export function appendSessionEvent(cwd, eventPayload, options = {}) {
192
221
  encoding: 'utf-8',
193
222
  flag: 'a',
194
223
  })
195
- writeActiveProjectSession(scope, {
196
- host: eventPayload.host || '',
197
- source: eventPayload.source || eventName,
198
- env: scopedOptions.env,
199
- })
200
224
  return scope.eventsPath
201
225
  }
202
226
 
203
227
  export function resetSessionEvents(cwd, options = {}) {
204
228
  const scope = getScope(cwd, options)
205
229
  if (scope.scope === 'project-session' && !scope.active) return ''
230
+ if (!shouldRecordSessionEvents(options)) return ''
206
231
  mkdirSync(dirname(scope.eventsPath), { recursive: true })
207
232
  writeFileSync(scope.eventsPath, '', 'utf-8')
208
233
  return scope.eventsPath
@@ -242,3 +267,16 @@ export function clearSessionArtifact(cwd, fileName, options = {}) {
242
267
  export function removeSessionCapsule(cwd, options = {}) {
243
268
  removeRuntimeFile(getSessionCapsulePath(cwd, options))
244
269
  }
270
+
271
+ function shouldRecordSessionEvents(options = {}) {
272
+ const normalizedOptions = normalizeOptions(options)
273
+ const payload = normalizedOptions.payload || {}
274
+ if (normalizedOptions.traceEvents === true || payload.traceEvents === true || payload._helloagentsTraceEvents === true) {
275
+ return true
276
+ }
277
+
278
+ const raw = String(normalizedOptions.env?.HELLOAGENTS_TRACE_EVENTS || process.env.HELLOAGENTS_TRACE_EVENTS || '')
279
+ .trim()
280
+ .toLowerCase()
281
+ return raw === '1' || raw === 'true' || raw === 'yes'
282
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ const STATE_META_BEGIN = '<!-- HELLOAGENTS_STATE_META'
5
+ const STATE_META_END = 'HELLOAGENTS_STATE_META -->'
6
+
7
+ function normalizeText(content = '') {
8
+ return String(content || '').replace(/^\uFEFF/, '')
9
+ }
10
+
11
+ function splitLines(content = '') {
12
+ return normalizeText(content).replace(/\r\n/g, '\n').split('\n')
13
+ }
14
+
15
+ export function parseStateDocument(content = '') {
16
+ const lines = splitLines(content)
17
+ if (lines[0]?.trim() !== STATE_META_BEGIN) {
18
+ return {
19
+ hasMetadata: false,
20
+ metadata: null,
21
+ body: normalizeText(content),
22
+ }
23
+ }
24
+
25
+ const endIndex = lines.findIndex((line, index) => index > 0 && line.trim() === STATE_META_END)
26
+ if (endIndex < 0) {
27
+ return {
28
+ hasMetadata: false,
29
+ metadata: null,
30
+ body: normalizeText(content),
31
+ }
32
+ }
33
+
34
+ const metadataText = lines.slice(1, endIndex).join('\n').trim()
35
+ const body = lines.slice(endIndex + 1).join('\n').replace(/^\n+/, '')
36
+ try {
37
+ return {
38
+ hasMetadata: true,
39
+ metadata: JSON.parse(metadataText),
40
+ body,
41
+ }
42
+ } catch {
43
+ return {
44
+ hasMetadata: false,
45
+ metadata: null,
46
+ body,
47
+ }
48
+ }
49
+ }
50
+
51
+ export function readStateDocument(filePath) {
52
+ if (!filePath || !existsSync(filePath)) {
53
+ return {
54
+ hasMetadata: false,
55
+ metadata: null,
56
+ body: '',
57
+ }
58
+ }
59
+
60
+ return parseStateDocument(readFileSync(filePath, 'utf-8'))
61
+ }
62
+
63
+ export function composeStateDocument({ metadata = {}, body = '' } = {}) {
64
+ const normalizedBody = normalizeText(body).replace(/^\n+/, '')
65
+ return [
66
+ STATE_META_BEGIN,
67
+ JSON.stringify(metadata, null, 2),
68
+ STATE_META_END,
69
+ '',
70
+ normalizedBody,
71
+ ].join('\n').replace(/\n+$/, '\n')
72
+ }
73
+
74
+ export function writeStateDocument(filePath, { metadata = {}, body = '' } = {}) {
75
+ mkdirSync(dirname(filePath), { recursive: true })
76
+ writeFileSync(filePath, composeStateDocument({ metadata, body }), 'utf-8')
77
+ }