helloagents 3.0.33 → 3.0.37

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -4
  4. package/README.md +78 -74
  5. package/README_CN.md +78 -74
  6. package/bootstrap-lite.md +9 -11
  7. package/bootstrap.md +21 -23
  8. package/gemini-extension.json +1 -1
  9. package/install.ps1 +27 -4
  10. package/install.sh +27 -3
  11. package/package.json +2 -2
  12. package/scripts/capability-registry.mjs +5 -3
  13. package/scripts/cli-doctor-codex.mjs +153 -1
  14. package/scripts/cli-doctor-render.mjs +2 -1
  15. package/scripts/cli-doctor.mjs +3 -3
  16. package/scripts/cli-hosts.mjs +1 -1
  17. package/scripts/cli-lifecycle-hosts.mjs +124 -54
  18. package/scripts/cli-lifecycle.mjs +50 -15
  19. package/scripts/cli-messages.mjs +7 -7
  20. package/scripts/cli-runtime-root.mjs +9 -1
  21. package/scripts/delivery-gate-messages.mjs +5 -4
  22. package/scripts/delivery-gate.mjs +11 -22
  23. package/scripts/guard.mjs +1 -1
  24. package/scripts/notify-closeout.mjs +61 -22
  25. package/scripts/notify-context.mjs +5 -5
  26. package/scripts/notify-route.mjs +1 -1
  27. package/scripts/notify-sound.mjs +2 -1
  28. package/scripts/notify.mjs +2 -2
  29. package/scripts/plan-contract.mjs +10 -14
  30. package/scripts/project-session-cleanup.mjs +91 -31
  31. package/scripts/qa-review-state.mjs +313 -0
  32. package/scripts/ralph-loop.mjs +32 -13
  33. package/scripts/runtime-artifacts.mjs +2 -2
  34. package/scripts/runtime-scope.mjs +14 -13
  35. package/scripts/runtime-ttl.mjs +7 -4
  36. package/scripts/session-capsule.mjs +75 -13
  37. package/scripts/session-token.mjs +44 -9
  38. package/scripts/state-document.mjs +77 -0
  39. package/scripts/workflow-core.mjs +13 -19
  40. package/scripts/workflow-plan-files.mjs +1 -1
  41. package/scripts/workflow-recommendation.mjs +55 -67
  42. package/scripts/workflow-state.mjs +8 -8
  43. package/skills/commands/auto/SKILL.md +12 -12
  44. package/skills/commands/build/SKILL.md +9 -10
  45. package/skills/commands/commit/SKILL.md +1 -1
  46. package/skills/commands/help/SKILL.md +11 -13
  47. package/skills/commands/init/SKILL.md +18 -9
  48. package/skills/commands/loop/SKILL.md +70 -96
  49. package/skills/commands/plan/SKILL.md +7 -8
  50. package/skills/commands/prd/SKILL.md +3 -3
  51. package/skills/commands/qa/SKILL.md +49 -0
  52. package/skills/hello-ui/SKILL.md +3 -3
  53. package/skills/helloagents/SKILL.md +11 -14
  54. package/skills/qa-review/SKILL.md +92 -0
  55. package/templates/plans/contract.json +4 -7
  56. package/templates/plans/plan.md +1 -1
  57. package/templates/plans/tasks.md +1 -1
  58. package/templates/verify.yaml +1 -1
  59. package/scripts/review-state.mjs +0 -193
  60. package/scripts/verify-state.mjs +0 -175
  61. package/skills/commands/global/SKILL.md +0 -71
  62. package/skills/commands/verify/SKILL.md +0 -46
  63. package/skills/commands/wiki/SKILL.md +0 -57
  64. package/skills/hello-review/SKILL.md +0 -42
  65. package/skills/hello-verify/SKILL.md +0 -144
  66. /package/hooks/{hooks.json → hooks-gemini.json} +0 -0
@@ -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
  }
@@ -7,7 +7,7 @@ import {
7
7
  readSessionArtifact,
8
8
  writeSessionArtifact,
9
9
  } from './session-capsule.mjs'
10
- import { EVIDENCE_MAX_AGE_MS, LONG_RUNNING_TTL_HOURS } from './runtime-ttl.mjs'
10
+ import { EVIDENCE_MAX_AGE_MS, LONG_RUNNING_TTL_HOURS, STANDARD_RUNTIME_TTL_HOURS } from './runtime-ttl.mjs'
11
11
 
12
12
  export { EVIDENCE_MAX_AGE_MS }
13
13
 
@@ -87,7 +87,7 @@ export function validateEvidenceTimestamp(evidence, now, label) {
87
87
  required: true,
88
88
  status: 'stale-time',
89
89
  evidence,
90
- details: [`${label}超过 ${LONG_RUNNING_TTL_HOURS} 小时`],
90
+ details: [`${label}超过 ${STANDARD_RUNTIME_TTL_HOURS} 小时(长任务上限:${LONG_RUNNING_TTL_HOURS} 小时)`],
91
91
  }
92
92
  }
93
93
  return null
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync,
4
4
  import { dirname, join, normalize, resolve } from 'node:path'
5
5
  import { homedir } from 'node:os'
6
6
 
7
- import { resolveSessionToken } from './session-token.mjs'
7
+ import { resolveProjectSessionToken, resolveSessionToken } from './session-token.mjs'
8
8
  import { USER_RUNTIME_MAX_AGE_MS } from './runtime-ttl.mjs'
9
9
  import { cleanupUserRuntimeRoot, getUserRuntimeRoot } from './runtime-user-cleanup.mjs'
10
10
  import { FULL_CARRIER_PROFILE_MARKER } from './cli-utils.mjs'
@@ -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'
@@ -324,21 +323,14 @@ function findProjectActivationDir(cwd) {
324
323
 
325
324
  function resolvePayloadSessionToken(payload = {}) {
326
325
  if (payload?._helloagentsSessionAlias) return ''
327
- return resolveSessionToken({
326
+ return resolveProjectSessionToken({
328
327
  payload,
329
328
  env: {},
330
- ppid: 0,
331
- allowPpidFallback: false,
332
329
  })
333
330
  }
334
331
 
335
332
  function resolveEnvSessionToken(env = process.env) {
336
- return resolveSessionToken({
337
- payload: {},
338
- env,
339
- ppid: 0,
340
- allowPpidFallback: false,
341
- })
333
+ return resolveProjectSessionToken({ payload: {}, env })
342
334
  }
343
335
 
344
336
  function resolveTransientSessionToken({ payload = {}, env = process.env, ppid = process.ppid } = {}) {
@@ -438,11 +430,21 @@ function chooseProjectSession({ payload, env, activationDir, projectRoot, worksp
438
430
  return { session: DEFAULT_STATE_SESSION_TOKEN, sessionMode: 'default' }
439
431
  }
440
432
 
433
+ function removeLegacyProjectArtifacts(activationDir) {
434
+ if (!activationDir) return
435
+ const artifactsDir = join(activationDir, PROJECT_ARTIFACTS_DIR_NAME)
436
+ if (!existsSync(artifactsDir)) return
437
+ try {
438
+ rmSync(artifactsDir, { recursive: true, force: true })
439
+ } catch {}
440
+ }
441
+
441
442
  export function getProjectSessionScope(cwd, options = {}) {
442
443
  const normalizedCwd = normalizePath(cwd || process.cwd())
443
444
  const projectRoot = getProjectRoot(normalizedCwd)
444
445
  const { payload = {}, env = process.env } = normalizeRuntimeOptions(options)
445
446
  const activationDir = getProjectActivationDir(projectRoot)
447
+ removeLegacyProjectArtifacts(activationDir)
446
448
  const workspace = resolveWorkspaceName(projectRoot)
447
449
  const { session, sessionMode } = chooseProjectSession({
448
450
  payload,
@@ -463,7 +465,6 @@ export function getProjectSessionScope(cwd, options = {}) {
463
465
  activationDir,
464
466
  sessionDir,
465
467
  statePath: join(sessionDir, 'STATE.md'),
466
- capsulePath: join(sessionDir, CAPSULE_FILE_NAME),
467
468
  eventsPath: join(sessionDir, EVENTS_FILE_NAME),
468
469
  artifactsDir: join(sessionDir, PROJECT_ARTIFACTS_DIR_NAME),
469
470
  key: `${projectRoot}::${workspace}::${session}`,
@@ -493,7 +494,7 @@ function buildTransientRuntimeDir(cwd, options = {}) {
493
494
  session: token,
494
495
  sessionMode: token === DEFAULT_STATE_SESSION_TOKEN ? 'default' : 'transient-session',
495
496
  sessionDir: join(getUserRuntimeRoot(), hash),
496
- capsulePath: join(getUserRuntimeRoot(), hash, CAPSULE_FILE_NAME),
497
+ statePath: join(getUserRuntimeRoot(), hash, 'STATE.md'),
497
498
  eventsPath: join(getUserRuntimeRoot(), hash, EVENTS_FILE_NAME),
498
499
  artifactsDir: join(getUserRuntimeRoot(), hash, PROJECT_ARTIFACTS_DIR_NAME),
499
500
  key: `${normalizedCwd}::transient::${token}`,
@@ -1,7 +1,10 @@
1
1
  export const LONG_RUNNING_TTL_HOURS = 720
2
2
  export const LONG_RUNNING_TTL_MS = LONG_RUNNING_TTL_HOURS * 60 * 60 * 1000
3
3
 
4
- export const ROUTE_CONTEXT_TTL_MS = LONG_RUNNING_TTL_MS
5
- export const TURN_STATE_TTL_MS = LONG_RUNNING_TTL_MS
6
- export const EVIDENCE_MAX_AGE_MS = LONG_RUNNING_TTL_MS
7
- export const USER_RUNTIME_MAX_AGE_MS = LONG_RUNNING_TTL_MS
4
+ export const STANDARD_RUNTIME_TTL_HOURS = 72
5
+ export const STANDARD_RUNTIME_TTL_MS = STANDARD_RUNTIME_TTL_HOURS * 60 * 60 * 1000
6
+
7
+ export const ROUTE_CONTEXT_TTL_MS = STANDARD_RUNTIME_TTL_MS
8
+ export const TURN_STATE_TTL_MS = STANDARD_RUNTIME_TTL_MS
9
+ export const EVIDENCE_MAX_AGE_MS = STANDARD_RUNTIME_TTL_MS
10
+ export const USER_RUNTIME_MAX_AGE_MS = STANDARD_RUNTIME_TTL_MS
@@ -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
 
@@ -67,8 +68,23 @@ function getScope(cwd, options = {}) {
67
68
  return getRuntimeScope(cwd, normalizedOptions)
68
69
  }
69
70
 
71
+ function shouldMaterializeSessionState(options = {}) {
72
+ const normalizedOptions = normalizeOptions(options)
73
+ if (normalizedOptions.ensureProjectLocal === true) return true
74
+ if (normalizedOptions.project === true) return true
75
+ if (normalizedOptions.traceEvents === true) return true
76
+
77
+ const payload = normalizedOptions.payload || {}
78
+ if (payload.traceEvents === true || payload._helloagentsTraceEvents === true) return true
79
+
80
+ const raw = String(normalizedOptions.env?.HELLOAGENTS_TRACE_EVENTS || process.env.HELLOAGENTS_TRACE_EVENTS || '')
81
+ .trim()
82
+ .toLowerCase()
83
+ return raw === '1' || raw === 'true' || raw === 'yes'
84
+ }
85
+
70
86
  export function getSessionCapsulePath(cwd = process.cwd(), options = {}) {
71
- return getScope(cwd, options).capsulePath
87
+ return getScope(cwd, options).statePath
72
88
  }
73
89
 
74
90
  export function getSessionEventsPath(cwd = process.cwd(), options = {}) {
@@ -93,8 +109,9 @@ export function getSessionArtifactRelativePath(cwd, fileName, options = {}) {
93
109
 
94
110
  export function readSessionCapsule(cwd = process.cwd(), options = {}) {
95
111
  const scope = getScope(cwd, options)
96
- const capsule = readJsonFile(scope.capsulePath, null)
97
- if (!capsule || typeof capsule !== 'object') return buildEmptyCapsule(scope)
112
+ const { metadata } = readStateDocument(scope.statePath)
113
+ const capsule = metadata && typeof metadata === 'object' ? metadata : null
114
+ if (!capsule || Array.isArray(capsule)) return buildEmptyCapsule(scope)
98
115
  return {
99
116
  ...buildEmptyCapsule(scope),
100
117
  ...capsule,
@@ -109,7 +126,33 @@ export function readSessionCapsule(cwd = process.cwd(), options = {}) {
109
126
  }
110
127
 
111
128
  export function writeSessionCapsule(cwd, capsule, options = {}) {
112
- const scope = getScope(cwd, options)
129
+ const normalizedOptions = normalizeOptions(options)
130
+ const scope = getScope(cwd, normalizedOptions)
131
+ const shouldMaterialize = shouldMaterializeSessionState(normalizedOptions)
132
+ const currentDocument = readStateDocument(scope.statePath)
133
+ const hasBody = Boolean(currentDocument.body && currentDocument.body.trim())
134
+ if (!hasBody && !shouldMaterialize && !existsSync(scope.statePath)) {
135
+ return {
136
+ ...buildEmptyCapsule(scope),
137
+ ...capsule,
138
+ scope: scope.scope,
139
+ key: scope.key,
140
+ cwd: scope.cwd,
141
+ branch: scope.branch,
142
+ workspace: scope.workspace || scope.branch,
143
+ session: scope.session,
144
+ sessionMode: scope.sessionMode,
145
+ updatedAt: new Date().toISOString(),
146
+ }
147
+ }
148
+ if (!hasBody && shouldMaterialize && !existsSync(scope.statePath)) {
149
+ ensureProjectLocalRuntime(cwd, {
150
+ ...normalizedOptions,
151
+ stateSeed: normalizedOptions.stateSeed && typeof normalizedOptions.stateSeed === 'object'
152
+ ? normalizedOptions.stateSeed
153
+ : {},
154
+ })
155
+ }
113
156
  const nextCapsule = {
114
157
  ...buildEmptyCapsule(scope),
115
158
  ...capsule,
@@ -122,9 +165,12 @@ export function writeSessionCapsule(cwd, capsule, options = {}) {
122
165
  sessionMode: scope.sessionMode,
123
166
  updatedAt: new Date().toISOString(),
124
167
  }
125
- writeJsonFileAtomic(scope.capsulePath, nextCapsule)
168
+ writeStateDocument(scope.statePath, {
169
+ metadata: nextCapsule,
170
+ body: currentDocument.body,
171
+ })
126
172
  writeActiveProjectSession(scope, {
127
- env: normalizeOptions(options).env,
173
+ env: normalizedOptions.env,
128
174
  })
129
175
  return nextCapsule
130
176
  }
@@ -151,8 +197,8 @@ export function writeCapsuleSection(cwd, section, value, options = {}) {
151
197
  }
152
198
 
153
199
  export function clearCapsuleSection(cwd, section, options = {}) {
154
- const capsulePath = getSessionCapsulePath(cwd, options)
155
- if (!existsSync(capsulePath)) return false
200
+ const statePath = getSessionCapsulePath(cwd, options)
201
+ if (!existsSync(statePath)) return false
156
202
 
157
203
  const capsule = readSessionCapsule(cwd, options)
158
204
  if (!Object.prototype.hasOwnProperty.call(capsule, section)) return false
@@ -180,6 +226,13 @@ export function appendSessionEvent(cwd, eventPayload, options = {}) {
180
226
  const eventName = eventPayload?.event || ''
181
227
  if (!eventName) return ''
182
228
 
229
+ writeActiveProjectSession(scope, {
230
+ host: eventPayload.host || '',
231
+ source: eventPayload.source || eventName,
232
+ env: scopedOptions.env,
233
+ })
234
+ if (!shouldRecordSessionEvents(scopedOptions)) return ''
235
+
183
236
  mkdirSync(dirname(scope.eventsPath), { recursive: true })
184
237
  const payload = {
185
238
  ts: new Date().toISOString(),
@@ -192,17 +245,13 @@ export function appendSessionEvent(cwd, eventPayload, options = {}) {
192
245
  encoding: 'utf-8',
193
246
  flag: 'a',
194
247
  })
195
- writeActiveProjectSession(scope, {
196
- host: eventPayload.host || '',
197
- source: eventPayload.source || eventName,
198
- env: scopedOptions.env,
199
- })
200
248
  return scope.eventsPath
201
249
  }
202
250
 
203
251
  export function resetSessionEvents(cwd, options = {}) {
204
252
  const scope = getScope(cwd, options)
205
253
  if (scope.scope === 'project-session' && !scope.active) return ''
254
+ if (!shouldRecordSessionEvents(options)) return ''
206
255
  mkdirSync(dirname(scope.eventsPath), { recursive: true })
207
256
  writeFileSync(scope.eventsPath, '', 'utf-8')
208
257
  return scope.eventsPath
@@ -242,3 +291,16 @@ export function clearSessionArtifact(cwd, fileName, options = {}) {
242
291
  export function removeSessionCapsule(cwd, options = {}) {
243
292
  removeRuntimeFile(getSessionCapsulePath(cwd, options))
244
293
  }
294
+
295
+ function shouldRecordSessionEvents(options = {}) {
296
+ const normalizedOptions = normalizeOptions(options)
297
+ const payload = normalizedOptions.payload || {}
298
+ if (normalizedOptions.traceEvents === true || payload.traceEvents === true || payload._helloagentsTraceEvents === true) {
299
+ return true
300
+ }
301
+
302
+ const raw = String(normalizedOptions.env?.HELLOAGENTS_TRACE_EVENTS || process.env.HELLOAGENTS_TRACE_EVENTS || '')
303
+ .trim()
304
+ .toLowerCase()
305
+ return raw === '1' || raw === 'true' || raw === 'yes'
306
+ }