Skip to content

Commit cb481e8

Browse files
committed
test(hooks): add Stop-gate hook replay matrix for R-T5/T3/T6
Runnable against deployed Grok lifecycle in an isolated temp cwd. Supports B5 preflight before interactive UI replay.
1 parent e32c1e1 commit cb481e8

1 file changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
'use strict'
2+
3+
/**
4+
* Hook-level replay matrix for Stop completion gate (R-T3 / R-T5 / R-T6).
5+
* Uses deployed Grok lifecycle when present; falls back to package source.
6+
* Isolated temp cwd — no workspace skills.
7+
*
8+
* Usage: node scripts/replay-stop-gate-hook-matrix.js
9+
*/
10+
11+
const { spawnSync } = require('child_process')
12+
const fs = require('fs')
13+
const path = require('path')
14+
const os = require('os')
15+
16+
const packageRoot = path.resolve(__dirname, '..')
17+
const deployedLifecycle = path.join(
18+
process.env.USERPROFILE || process.env.HOME || '',
19+
'.grok',
20+
'devcodex',
21+
'runtime',
22+
'hooks',
23+
'_runtime',
24+
'lifecycle.cjs'
25+
)
26+
const lifecycle = fs.existsSync(deployedLifecycle)
27+
? deployedLifecycle
28+
: path.join(packageRoot, 'hooks', '_runtime', 'lifecycle.cjs')
29+
30+
const FULL_ENTRY = [
31+
'### DevCodex · 入口检查',
32+
'- PC0 [PASS] Context plan',
33+
'- PC1 [PASS] Intent',
34+
'- PC2 [PASS] Session',
35+
'- PC3 [PASS] Project',
36+
'- PC4 [N/A] skipReason=non-dev',
37+
'- PC5 [PASS] Host',
38+
'- PC6 [PASS] Git',
39+
'- PC7 [PASS] Next'
40+
].join('\n')
41+
42+
const FINAL_VALIDATION_GOOD = [
43+
'#### 验证摘要',
44+
'| 类型 | 命令 | exitCode | runId/计数 |',
45+
'| 权威 | `npm run test:visible-output` | exitCode 0 | runId=validation-202607230001 / checks=42 |',
46+
'WorkspaceSyncStatus: skipped (无需同步)',
47+
'dirty boundary: git status clean; no unrelated dirty',
48+
'Release actions: push/tag/release/publish 未执行'
49+
].join('\n')
50+
51+
// Match scripts/test-visible-output-contract.js finalValidationGood (verified-present)
52+
const COMPLETE_BODY = [
53+
FULL_ENTRY,
54+
'',
55+
'### DevCodex · 完成检查',
56+
'| 类型 | 命令 | exitCode | runId/计数 |',
57+
'| 权威 | `npm run test:visible-output` | exitCode 0 | runId=validation-202607230001 / checks=42 |',
58+
'WorkspaceSyncStatus: skipped (无需同步)',
59+
'dirty boundary: git status clean; no unrelated dirty',
60+
'Release actions: push/tag/release/publish 未执行',
61+
'`DevCodexVisibleEnvelopeV1 · completion-check · PASS · ' + 'c'.repeat(64) + '`',
62+
'',
63+
'工作已完成并收口。'
64+
].join('\n')
65+
66+
const INCOMPLETE_BODY =
67+
'任务已完成、已收口。All work is complete. 无需入口检查。'
68+
69+
function makeEnv (sessionTag) {
70+
return {
71+
...process.env,
72+
DEVCODEX_HOST_PLATFORM: 'grok',
73+
GROK_HOME: path.join(process.env.USERPROFILE || process.env.HOME || '', '.grok'),
74+
GROK_SESSION_ID: sessionTag,
75+
GROK_BUILD: '1'
76+
}
77+
}
78+
79+
function runLifecycle (cwd, env, payload) {
80+
const r = spawnSync(process.execPath, [lifecycle], {
81+
cwd,
82+
env,
83+
input: JSON.stringify(payload),
84+
encoding: 'utf8',
85+
timeout: 60000,
86+
maxBuffer: 4 * 1024 * 1024
87+
})
88+
let json = null
89+
try {
90+
json = JSON.parse(String(r.stdout || '').trim() || '{}')
91+
} catch {
92+
json = null
93+
}
94+
return {
95+
status: r.status,
96+
stdout: String(r.stdout || ''),
97+
stderr: String(r.stderr || ''),
98+
json
99+
}
100+
}
101+
102+
function touchMutation (cwd, env, name) {
103+
const tmp = path.join(cwd, name)
104+
fs.writeFileSync(tmp, 'replay-only\n', 'utf8')
105+
return runLifecycle(cwd, env, {
106+
hookEventName: 'PostToolUse',
107+
tool_name: 'create_file',
108+
tool_input: { path: tmp, content: 'replay-only' },
109+
cwd
110+
})
111+
}
112+
113+
function readHonesty (cwd) {
114+
const statePath = path.join(cwd, '.devcodex', '.memory', 'hooks', 'legacy', 'lifecycle-state.json')
115+
if (!fs.existsSync(statePath)) return null
116+
try {
117+
const st = JSON.parse(fs.readFileSync(statePath, 'utf8'))
118+
return {
119+
mutated: !!st.mutated,
120+
reportTouched: !!st.reportTouched,
121+
memoryTouched: !!st.memoryTouched,
122+
mode: st.mode,
123+
honesty: st.enforcementHonesty || null
124+
}
125+
} catch {
126+
return null
127+
}
128+
}
129+
130+
function classifyStop (json) {
131+
if (!json || typeof json !== 'object') return 'unknown'
132+
if (json.decision === 'block') return 'block'
133+
if (json.decision === 'allow') return 'allow'
134+
if (json.continue === true && !json.decision) return 'soft-or-allow'
135+
return 'other'
136+
}
137+
138+
function scenarioRt5 () {
139+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'devcodex-rt5-'))
140+
fs.writeFileSync(path.join(cwd, 'README.md'), 'rt5-chat\n', 'utf8')
141+
const env = makeEnv('rt5-' + Date.now())
142+
runLifecycle(cwd, env, {
143+
hookEventName: 'UserPromptSubmit',
144+
prompt: '今天天气怎样,只闲聊,不要写任何文件',
145+
cwd
146+
})
147+
// no mutation
148+
const stop = runLifecycle(cwd, env, {
149+
hookEventName: 'Stop',
150+
lastAssistantMessage: '今天天气不错,适合散步。纯闲聊,无代码变更。',
151+
stopHookActive: false,
152+
cwd
153+
})
154+
const kind = classifyStop(stop.json)
155+
const state = readHonesty(cwd)
156+
const pass =
157+
kind !== 'block' &&
158+
!/Stop gate: incomplete/i.test(String(stop.json?.reason || ''))
159+
return {
160+
id: 'R-T5',
161+
pass,
162+
kind,
163+
decision: stop.json?.decision || null,
164+
reason: stop.json?.reason || stop.json?.systemMessage || '',
165+
state,
166+
cwd
167+
}
168+
}
169+
170+
function scenarioRt3 () {
171+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'devcodex-rt3-'))
172+
fs.writeFileSync(path.join(cwd, 'README.md'), 'rt3\n', 'utf8')
173+
const env = makeEnv('rt3-' + Date.now())
174+
runLifecycle(cwd, env, {
175+
hookEventName: 'UserPromptSubmit',
176+
prompt: 'write tmp then claim complete without entry check',
177+
cwd
178+
})
179+
touchMutation(cwd, env, '_replay_stop_gate_tmp.txt')
180+
const stop = runLifecycle(cwd, env, {
181+
hookEventName: 'Stop',
182+
lastAssistantMessage: INCOMPLETE_BODY,
183+
stopHookActive: false,
184+
cwd
185+
})
186+
const kind = classifyStop(stop.json)
187+
const reason = String(stop.json?.reason || '')
188+
const pass =
189+
kind === 'block' &&
190+
/Stop gate|incomplete|entry-check/i.test(reason)
191+
return {
192+
id: 'R-T3',
193+
pass,
194+
kind,
195+
decision: stop.json?.decision || null,
196+
reason,
197+
state: readHonesty(cwd),
198+
cwd
199+
}
200+
}
201+
202+
function scenarioRt6 () {
203+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'devcodex-rt6-'))
204+
fs.writeFileSync(path.join(cwd, 'README.md'), 'rt6\n', 'utf8')
205+
// Minimal product artifact paths so report/memory touches can fire if tool paths hit them
206+
const reportDir = path.join(cwd, '.devcodex', 'reports', 'analysis', 'grok', '20260726')
207+
const memDir = path.join(cwd, '.devcodex', '.memory')
208+
fs.mkdirSync(reportDir, { recursive: true })
209+
fs.mkdirSync(memDir, { recursive: true })
210+
const reportFile = path.join(reportDir, '01--rt6.md')
211+
const sessionsFile = path.join(memDir, 'sessions.md')
212+
fs.writeFileSync(reportFile, '# rt6 report\n', 'utf8')
213+
fs.writeFileSync(sessionsFile, '# sessions\n', 'utf8')
214+
215+
const env = makeEnv('rt6-' + Date.now())
216+
runLifecycle(cwd, env, {
217+
hookEventName: 'UserPromptSubmit',
218+
prompt: 'mutate then complete with full entry and FVS',
219+
cwd
220+
})
221+
touchMutation(cwd, env, '_rt6_src.txt')
222+
// mark report + memory touched via product-like tool paths
223+
runLifecycle(cwd, env, {
224+
hookEventName: 'PostToolUse',
225+
tool_name: 'create_file',
226+
tool_input: { path: reportFile, content: '# rt6 report\n' },
227+
cwd
228+
})
229+
runLifecycle(cwd, env, {
230+
hookEventName: 'PostToolUse',
231+
tool_name: 'create_file',
232+
tool_input: { path: sessionsFile, content: '# sessions\n' },
233+
cwd
234+
})
235+
236+
const stop = runLifecycle(cwd, env, {
237+
hookEventName: 'Stop',
238+
lastAssistantMessage: COMPLETE_BODY,
239+
stopHookActive: false,
240+
cwd
241+
})
242+
const kind = classifyStop(stop.json)
243+
const reason = String(stop.json?.reason || stop.json?.systemMessage || '')
244+
// Expect no completion-gate hard block (allow or soft-only reminder ok)
245+
const hardCompletionBlock =
246+
kind === 'block' && /Stop gate: incomplete/i.test(reason)
247+
const pass = !hardCompletionBlock
248+
return {
249+
id: 'R-T6',
250+
pass,
251+
kind,
252+
decision: stop.json?.decision || null,
253+
reason: reason.slice(0, 400),
254+
state: readHonesty(cwd),
255+
cwd
256+
}
257+
}
258+
259+
function main () {
260+
console.log('lifecycle:', lifecycle)
261+
const results = [scenarioRt5(), scenarioRt3(), scenarioRt6()]
262+
let failed = 0
263+
for (const r of results) {
264+
const mark = r.pass ? 'PASS' : 'FAIL'
265+
if (!r.pass) failed += 1
266+
console.log(`\n=== ${r.id} ${mark} ===`)
267+
console.log('kind:', r.kind, 'decision:', r.decision)
268+
console.log('reason:', String(r.reason || '').slice(0, 280))
269+
console.log('state:', JSON.stringify(r.state))
270+
console.log('cwd:', r.cwd)
271+
}
272+
console.log('\n=== SUMMARY ===')
273+
console.log(
274+
results.map(r => `${r.id}:${r.pass ? 'PASS' : 'FAIL'}`).join(' ')
275+
)
276+
process.exitCode = failed ? 1 : 0
277+
}
278+
279+
main()

0 commit comments

Comments
 (0)