@syntesseraai/opencode-feature-factory 0.1.9 → 0.1.10
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/package.json +2 -2
- package/src/stop-quality-gate.ts +105 -9
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@syntesseraai/opencode-feature-factory",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.10",
|
|
5
5
|
"description": "OpenCode plugin for Feature Factory agents - provides planning, implementation, review, testing, and validation agents",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -31,4 +31,4 @@
|
|
|
31
31
|
"@types/node": "^22.0.0",
|
|
32
32
|
"typescript": "^5.0.0"
|
|
33
33
|
}
|
|
34
|
-
}
|
|
34
|
+
}
|
package/src/stop-quality-gate.ts
CHANGED
|
@@ -22,6 +22,10 @@ interface ExtendedSessionState extends SessionState {
|
|
|
22
22
|
qualityGatePassed: boolean;
|
|
23
23
|
/** Debounce timer for session.idle */
|
|
24
24
|
idleDebounce: ReturnType<typeof setTimeout> | null;
|
|
25
|
+
/** Parent session ID (if this is a sub-agent session) */
|
|
26
|
+
parentID?: string;
|
|
27
|
+
/** Whether this session is read-only (edit or bash denied). undefined = not yet determined */
|
|
28
|
+
isReadOnly?: boolean;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const sessions = new Map<string, ExtendedSessionState>();
|
|
@@ -41,6 +45,28 @@ function getSessionState(sessionId: string): ExtendedSessionState {
|
|
|
41
45
|
return state;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Permission Helpers
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/** Permission rule structure from OpenCode SDK */
|
|
53
|
+
interface PermissionRule {
|
|
54
|
+
permission: string;
|
|
55
|
+
pattern: string;
|
|
56
|
+
action: 'allow' | 'deny' | 'ask';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a session is read-only based on its permission rules.
|
|
61
|
+
* A session is considered read-only if either 'edit' or 'bash' permission is denied.
|
|
62
|
+
*/
|
|
63
|
+
function isSessionReadOnly(permission?: PermissionRule[]): boolean {
|
|
64
|
+
if (!permission) return false;
|
|
65
|
+
return permission.some(
|
|
66
|
+
(rule) => (rule.permission === 'edit' || rule.permission === 'bash') && rule.action === 'deny'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
// ============================================================================
|
|
45
71
|
// Global Execution Lock
|
|
46
72
|
// ============================================================================
|
|
@@ -309,6 +335,40 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
309
335
|
const cacheSeconds = qualityGate.cacheSeconds ?? DEFAULT_QUALITY_GATE.cacheSeconds;
|
|
310
336
|
const cwd = qualityGate.cwd ? `${directory}/${qualityGate.cwd}` : directory;
|
|
311
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Ensure session metadata (parentID, isReadOnly) is populated.
|
|
340
|
+
* Fetches from API if not already known (fallback for late-started plugin).
|
|
341
|
+
*/
|
|
342
|
+
async function ensureSessionMetadata(
|
|
343
|
+
sessionId: string,
|
|
344
|
+
state: ExtendedSessionState
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
// Already have metadata
|
|
347
|
+
if (state.isReadOnly !== undefined) return;
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const response = await client.session.get({ path: { id: sessionId } });
|
|
351
|
+
if (response.data) {
|
|
352
|
+
state.parentID = response.data.parentID;
|
|
353
|
+
// Cast to access permission field (available in API but not in v1 SDK types)
|
|
354
|
+
const permission = (response.data as { permission?: PermissionRule[] }).permission;
|
|
355
|
+
state.isReadOnly = isSessionReadOnly(permission);
|
|
356
|
+
await log(client, 'debug', 'session.metadata-fetched', {
|
|
357
|
+
sessionId,
|
|
358
|
+
parentID: state.parentID,
|
|
359
|
+
isReadOnly: state.isReadOnly,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
// If fetch fails, default to running quality gate (current behavior)
|
|
364
|
+
state.isReadOnly = false;
|
|
365
|
+
await log(client, 'warn', 'session.metadata-fetch-failed', {
|
|
366
|
+
sessionId,
|
|
367
|
+
error: String(err),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
312
372
|
/**
|
|
313
373
|
* Run the quality gate checks for a session.
|
|
314
374
|
* Uses a global lock to prevent concurrent executions.
|
|
@@ -460,15 +520,33 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
460
520
|
const eventProps = (event as { properties?: { sessionID?: string } }).properties;
|
|
461
521
|
const sessionId = eventProps?.sessionID;
|
|
462
522
|
|
|
463
|
-
if (event.type === 'session.created'
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
523
|
+
if (event.type === 'session.created') {
|
|
524
|
+
// Extract full session info from the event
|
|
525
|
+
const sessionInfo = (
|
|
526
|
+
event as {
|
|
527
|
+
properties?: {
|
|
528
|
+
info?: { id: string; parentID?: string; permission?: PermissionRule[] };
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
).properties?.info;
|
|
532
|
+
const id = sessionInfo?.id;
|
|
533
|
+
if (id) {
|
|
534
|
+
const isReadOnly = isSessionReadOnly(sessionInfo?.permission);
|
|
535
|
+
sessions.set(id, {
|
|
536
|
+
lastRunAt: 0,
|
|
537
|
+
lastResults: [],
|
|
538
|
+
dirty: true,
|
|
539
|
+
qualityGatePassed: false,
|
|
540
|
+
idleDebounce: null,
|
|
541
|
+
parentID: sessionInfo?.parentID,
|
|
542
|
+
isReadOnly,
|
|
543
|
+
});
|
|
544
|
+
await log(client, 'debug', 'session.created', {
|
|
545
|
+
sessionId: id,
|
|
546
|
+
parentID: sessionInfo?.parentID,
|
|
547
|
+
isReadOnly,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
472
550
|
}
|
|
473
551
|
|
|
474
552
|
if (event.type === 'session.deleted' && sessionId) {
|
|
@@ -490,6 +568,24 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
490
568
|
return;
|
|
491
569
|
}
|
|
492
570
|
|
|
571
|
+
// Ensure we have session metadata (fetch if needed for late-started plugin)
|
|
572
|
+
await ensureSessionMetadata(sessionId, state);
|
|
573
|
+
|
|
574
|
+
// Skip quality gate for sub-agent sessions (sessions with a parent)
|
|
575
|
+
if (state.parentID) {
|
|
576
|
+
await log(client, 'debug', 'quality-gate.skipped (sub-agent session)', {
|
|
577
|
+
sessionId,
|
|
578
|
+
parentID: state.parentID,
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Skip quality gate for read-only sessions (edit or bash denied)
|
|
584
|
+
if (state.isReadOnly) {
|
|
585
|
+
await log(client, 'debug', 'quality-gate.skipped (read-only session)', { sessionId });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
493
589
|
// Debounce to avoid running multiple times
|
|
494
590
|
if (state.idleDebounce) {
|
|
495
591
|
clearTimeout(state.idleDebounce);
|