Skip to content

Commit bc4e613

Browse files
committed
phase9-wave5-t55-c1a-seq116: add objective lifecycle governance events
Land VRE-side objective lifecycle governance event adoption for CLI-owned objective transitions. objective start/stop/pause/resume now emit objective_started/objective_completed/objective_paused/objective_resumed through the seq 113 plugin bridge after durable state commits, with sanitized details and fail-soft telemetry. Tests: objective-cli 17/17; objective-lock 9/9; VRE test:phase9 398 pass / 0 fail / 5 skipped; VRE validate 13/13; plugin test:phase9 48/48; plugin npm test 173 e2e + 342 phase8 + 48 phase9; wiki-lint issueCount 0.
1 parent 1849afd commit bc4e613

5 files changed

Lines changed: 304 additions & 0 deletions

File tree

environment/objectives/cli.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ import {
3737
writeResumeSnapshot
3838
} from './resume-snapshot.js';
3939
import { assertReviewer2Gate } from '../orchestrator/agent-orchestration.js';
40+
import { logGovernanceEventViaPlugin } from '../orchestrator/governance-logger.js';
41+
42+
const OBJECTIVE_CLI_GOVERNANCE_SOURCE_COMPONENT = 'vre/objectives/cli';
43+
44+
async function recordObjectiveLifecycleGovernanceEvent(eventType, objectiveId, details) {
45+
try {
46+
await logGovernanceEventViaPlugin({
47+
event_type: eventType,
48+
source_component: OBJECTIVE_CLI_GOVERNANCE_SOURCE_COMPONENT,
49+
objective_id: objectiveId,
50+
severity: 'info',
51+
details
52+
});
53+
} catch (error) {
54+
const code = typeof error?.code === 'string' ? error.code : 'E_GOVERNANCE_BRIDGE_FAILED';
55+
process.stderr.write(`[phase9-governance] ${eventType} telemetry failed: ${code}\n`);
56+
}
57+
}
4058

4159
function toRepoRelative(projectRoot, targetPath) {
4260
return normalizeSlashes(path.relative(projectRoot, targetPath));
@@ -289,6 +307,14 @@ export async function startObjectiveCommand(repoRoot, { objectiveRecord, session
289307
await rm(path.dirname(objectiveRecordPath(repoRoot, activation.objectiveRecord.objectiveId)), { recursive: true, force: true }).catch(() => {});
290308
throw error;
291309
}
310+
await recordObjectiveLifecycleGovernanceEvent(
311+
'objective_started',
312+
activation.objectiveRecord.objectiveId,
313+
{
314+
runtimeMode: activation.objectiveRecord.runtimeMode,
315+
reasoningMode: activation.objectiveRecord.reasoningMode
316+
}
317+
);
292318

293319
return phase9SuccessPayload('objective start', {
294320
objectiveId: activation.objectiveRecord.objectiveId,
@@ -318,6 +344,9 @@ export async function pauseObjectiveCommand(repoRoot, { objectiveId, reason }, d
318344
}
319345
);
320346
const persistedHandshake = await persistHandshake(repoRoot, deps);
347+
await recordObjectiveLifecycleGovernanceEvent('objective_paused', objectiveId, {
348+
pauseReason: 'operator-pause'
349+
});
321350
return phase9SuccessPayload('objective pause', {
322351
objectiveId,
323352
reason,
@@ -354,6 +383,9 @@ export async function stopObjectiveCommand(repoRoot, { objectiveId, reason }, de
354383
}
355384
);
356385
const persistedHandshake = await persistHandshake(repoRoot, deps);
386+
await recordObjectiveLifecycleGovernanceEvent('objective_completed', objectiveId, {
387+
terminalStatus: result.objectiveRecord.status
388+
});
357389
return phase9SuccessPayload('objective stop', {
358390
objectiveId,
359391
reason,
@@ -470,6 +502,10 @@ export async function resumeObjectiveCommand(repoRoot, { objectiveId, repairSnap
470502
blockerResolved
471503
});
472504
const persistedHandshake = await persistHandshake(repoRoot, deps);
505+
await recordObjectiveLifecycleGovernanceEvent('objective_resumed', objectiveId, {
506+
repairSnapshot: Boolean(repairSnapshot),
507+
blockerResolved
508+
});
473509

474510
return phase9SuccessPayload('objective resume', {
475511
objectiveId,

environment/tests/cli/objective-cli.test.js

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ const FIXTURE_KERNEL_ENV = {
2323
'tests',
2424
'fixtures',
2525
'fake-kernel-sibling'
26+
),
27+
VIBE_SCIENCE_PLUGIN_CLI: path.join(
28+
repoRoot,
29+
'environment',
30+
'tests',
31+
'fixtures',
32+
'governance-log-capture-stub.js'
2633
)
2734
};
2835

@@ -71,6 +78,33 @@ async function readJson(targetPath) {
7178
return JSON.parse(await readFile(targetPath, 'utf8'));
7279
}
7380

81+
async function readGovernanceEvents(capturePath) {
82+
try {
83+
const raw = await readFile(capturePath, 'utf8');
84+
return raw
85+
.trim()
86+
.split(/\r?\n/u)
87+
.filter(Boolean)
88+
.map((line) => JSON.parse(line));
89+
} catch (error) {
90+
if (error?.code === 'ENOENT') {
91+
return [];
92+
}
93+
throw error;
94+
}
95+
}
96+
97+
function assertNoDetailsLeak(event, forbiddenValues) {
98+
const serialized = JSON.stringify(event.details ?? {});
99+
for (const value of forbiddenValues) {
100+
assert.equal(
101+
serialized.includes(value),
102+
false,
103+
`governance details leaked forbidden value ${value}: ${serialized}`
104+
);
105+
}
106+
}
107+
74108
async function readFixtureJson(section, fileName) {
75109
return JSON.parse(
76110
await readFile(
@@ -123,6 +157,212 @@ async function writeBlockerFlag(projectRoot, objectiveId) {
123157
});
124158
}
125159

160+
test('objective start emits an objective_started governance event after state commit', async () => {
161+
const projectRoot = await createCliFixtureProject('vre-objective-cli-governance-start-');
162+
const capturePath = path.join(projectRoot, '.governance-events.jsonl');
163+
try {
164+
const result = await runVre(projectRoot, buildObjectiveStartArgs(), {
165+
env: {
166+
...FIXTURE_KERNEL_ENV,
167+
VRE_SESSION_ID: 'sess-governance-start',
168+
VRE_GOVERNANCE_CAPTURE_PATH: capturePath
169+
}
170+
});
171+
assert.equal(result.code, 0, `stderr=${result.stderr}`);
172+
173+
const objectiveRecord = await readJson(
174+
path.join(projectRoot, '.vibe-science-environment', 'objectives', 'OBJ-001', 'objective.json')
175+
);
176+
assert.equal(objectiveRecord.status, 'active');
177+
178+
const events = await readGovernanceEvents(capturePath);
179+
assert.equal(events.length, 1);
180+
assert.equal(events[0].event_type, 'objective_started');
181+
assert.equal(events[0].source_component, 'vre/objectives/cli');
182+
assert.equal(events[0].objective_id, 'OBJ-001');
183+
assert.deepEqual(events[0].details, {
184+
runtimeMode: 'unattended-batch',
185+
reasoningMode: 'rule-only'
186+
});
187+
assertNoDetailsLeak(events[0], ['demo objective', 'why-now', projectRoot]);
188+
} finally {
189+
await cleanupCliFixtureProject(projectRoot);
190+
}
191+
});
192+
193+
test('objective stop emits an objective_completed governance event with terminal status only', async () => {
194+
const projectRoot = await createCliFixtureProject('vre-objective-cli-governance-stop-');
195+
const capturePath = path.join(projectRoot, '.governance-events.jsonl');
196+
const secretReason = 'operator closure SECRET-seq116-stop';
197+
try {
198+
await runVre(projectRoot, buildObjectiveStartArgs(), {
199+
env: {
200+
...FIXTURE_KERNEL_ENV,
201+
VRE_SESSION_ID: 'sess-governance-stop'
202+
}
203+
});
204+
205+
const result = await runVre(projectRoot, [
206+
'objective',
207+
'stop',
208+
'--objective',
209+
'OBJ-001',
210+
'--reason',
211+
secretReason
212+
], {
213+
env: {
214+
...FIXTURE_KERNEL_ENV,
215+
VRE_GOVERNANCE_CAPTURE_PATH: capturePath
216+
}
217+
});
218+
assert.equal(result.code, 0, `stderr=${result.stderr}`);
219+
220+
const objectiveRecord = await readJson(
221+
path.join(projectRoot, '.vibe-science-environment', 'objectives', 'OBJ-001', 'objective.json')
222+
);
223+
assert.equal(objectiveRecord.status, 'abandoned');
224+
225+
const events = await readGovernanceEvents(capturePath);
226+
assert.equal(events.length, 1);
227+
assert.equal(events[0].event_type, 'objective_completed');
228+
assert.equal(events[0].source_component, 'vre/objectives/cli');
229+
assert.equal(events[0].objective_id, 'OBJ-001');
230+
assert.deepEqual(events[0].details, {
231+
terminalStatus: 'abandoned'
232+
});
233+
assertNoDetailsLeak(events[0], [secretReason, projectRoot, 'active-objective.json']);
234+
} finally {
235+
await cleanupCliFixtureProject(projectRoot);
236+
}
237+
});
238+
239+
test('objective pause emits an objective_paused governance event without raw operator reason', async () => {
240+
const projectRoot = await createCliFixtureProject('vre-objective-cli-governance-pause-');
241+
const capturePath = path.join(projectRoot, '.governance-events.jsonl');
242+
const rawReason = 'operator pause SECRET-seq116-pause C:/sensitive/path';
243+
try {
244+
await runVre(projectRoot, buildObjectiveStartArgs(), {
245+
env: {
246+
...FIXTURE_KERNEL_ENV,
247+
VRE_SESSION_ID: 'sess-governance-pause'
248+
}
249+
});
250+
251+
const result = await runVre(projectRoot, [
252+
'objective',
253+
'pause',
254+
'--objective',
255+
'OBJ-001',
256+
'--reason',
257+
rawReason
258+
], {
259+
env: {
260+
...FIXTURE_KERNEL_ENV,
261+
VRE_GOVERNANCE_CAPTURE_PATH: capturePath
262+
}
263+
});
264+
assert.equal(result.code, 0, `stderr=${result.stderr}`);
265+
266+
const objectiveRecord = await readJson(
267+
path.join(projectRoot, '.vibe-science-environment', 'objectives', 'OBJ-001', 'objective.json')
268+
);
269+
assert.equal(objectiveRecord.status, 'paused');
270+
271+
const events = await readGovernanceEvents(capturePath);
272+
assert.equal(events.length, 1);
273+
assert.equal(events[0].event_type, 'objective_paused');
274+
assert.equal(events[0].source_component, 'vre/objectives/cli');
275+
assert.equal(events[0].objective_id, 'OBJ-001');
276+
assert.deepEqual(events[0].details, {
277+
pauseReason: 'operator-pause'
278+
});
279+
assertNoDetailsLeak(events[0], [rawReason, 'SECRET-seq116-pause', 'C:/sensitive/path']);
280+
} finally {
281+
await cleanupCliFixtureProject(projectRoot);
282+
}
283+
});
284+
285+
test('objective resume emits an objective_resumed governance event with boolean details', async () => {
286+
const projectRoot = await createCliFixtureProject('vre-objective-cli-governance-resume-');
287+
const capturePath = path.join(projectRoot, '.governance-events.jsonl');
288+
try {
289+
await runVre(projectRoot, buildObjectiveStartArgs(), {
290+
env: {
291+
...FIXTURE_KERNEL_ENV,
292+
VRE_SESSION_ID: 'sess-governance-resume'
293+
}
294+
});
295+
await runVre(projectRoot, [
296+
'objective',
297+
'pause',
298+
'--objective',
299+
'OBJ-001',
300+
'--reason',
301+
'operator pause before resume'
302+
], {
303+
env: FIXTURE_KERNEL_ENV
304+
});
305+
306+
const result = await runVre(projectRoot, [
307+
'objective',
308+
'resume',
309+
'--objective',
310+
'OBJ-001'
311+
], {
312+
env: {
313+
...FIXTURE_KERNEL_ENV,
314+
VRE_GOVERNANCE_CAPTURE_PATH: capturePath
315+
}
316+
});
317+
assert.equal(result.code, 0, `stderr=${result.stderr}`);
318+
319+
const objectiveRecord = await readJson(
320+
path.join(projectRoot, '.vibe-science-environment', 'objectives', 'OBJ-001', 'objective.json')
321+
);
322+
assert.equal(objectiveRecord.status, 'active');
323+
324+
const events = await readGovernanceEvents(capturePath);
325+
assert.equal(events.length, 1);
326+
assert.equal(events[0].event_type, 'objective_resumed');
327+
assert.equal(events[0].source_component, 'vre/objectives/cli');
328+
assert.equal(events[0].objective_id, 'OBJ-001');
329+
assert.deepEqual(events[0].details, {
330+
repairSnapshot: false,
331+
blockerResolved: false
332+
});
333+
assertNoDetailsLeak(events[0], [projectRoot, 'resume-snapshot.json', 'operator pause before resume']);
334+
} finally {
335+
await cleanupCliFixtureProject(projectRoot);
336+
}
337+
});
338+
339+
test('objective lifecycle governance bridge failure does not roll back the objective command', async () => {
340+
const projectRoot = await createCliFixtureProject('vre-objective-cli-governance-fail-soft-');
341+
const missingCli = path.join(projectRoot, 'missing-governance-log.js');
342+
try {
343+
const result = await runVre(projectRoot, buildObjectiveStartArgs(), {
344+
env: {
345+
...FIXTURE_KERNEL_ENV,
346+
VRE_SESSION_ID: 'sess-governance-fail-soft',
347+
VIBE_SCIENCE_PLUGIN_CLI: missingCli
348+
}
349+
});
350+
assert.equal(result.code, 0, `stderr=${result.stderr}`);
351+
assert.doesNotMatch(result.stderr, new RegExp(missingCli.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'u'));
352+
353+
const payload = JSON.parse(result.stdout);
354+
assert.equal(payload.ok, true);
355+
assert.equal(payload.objectiveId, 'OBJ-001');
356+
357+
const objectiveRecord = await readJson(
358+
path.join(projectRoot, '.vibe-science-environment', 'objectives', 'OBJ-001', 'objective.json')
359+
);
360+
assert.equal(objectiveRecord.status, 'active');
361+
} finally {
362+
await cleanupCliFixtureProject(projectRoot);
363+
}
364+
});
365+
126366
test('objective start rejects unattended-batch without a wake policy and emits structured JSON', async () => {
127367
const projectRoot = await createCliFixtureProject('vre-objective-cli-no-wake-');
128368
try {

environment/tests/control/objective-lock.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ const FIXTURE_KERNEL_ENV = {
1717
'tests',
1818
'fixtures',
1919
'fake-kernel-sibling'
20+
),
21+
VIBE_SCIENCE_PLUGIN_CLI: path.join(
22+
repoRoot,
23+
'environment',
24+
'tests',
25+
'fixtures',
26+
'governance-log-capture-stub.js'
2027
)
2128
};
2229

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { appendFile } from 'node:fs/promises';
2+
3+
let stdin = '';
4+
process.stdin.setEncoding('utf8');
5+
6+
for await (const chunk of process.stdin) {
7+
stdin += chunk;
8+
}
9+
10+
const payload = JSON.parse(stdin || '{}');
11+
const capturePath = process.env.VRE_GOVERNANCE_CAPTURE_PATH;
12+
if (capturePath) {
13+
await appendFile(capturePath, `${JSON.stringify(payload)}\n`, 'utf8');
14+
}
15+
16+
process.stdout.write(JSON.stringify({
17+
ok: true,
18+
eventId: 'GOV-STUB-001',
19+
code: 'OK'
20+
}));

0 commit comments

Comments
 (0)